├── .gitignore ├── .github ├── action-rs │ └── grcov.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── justfile ├── src ├── lib.rs ├── error.rs ├── upload_file.rs ├── value.rs ├── serde.rs ├── json.rs ├── query_parser.rs └── params.rs ├── codecov.yml ├── .pre-commit-config.yaml ├── LICENSE ├── Cargo.toml ├── examples ├── basic_params.rs ├── file_upload.rs └── nested_params.rs ├── CHANGELOG.md ├── README.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.github/action-rs/grcov.yml: -------------------------------------------------------------------------------- 1 | output-type: lcov 2 | output-file: ./lcov.info 3 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Run all tasks 2 | all: 3 | just fmt clippy 4 | 5 | # Format the code 6 | fmt: 7 | cargo fmt --all 8 | 9 | # Run Clippy for linting 10 | clippy: 11 | cargo clippy --all -- -D warnings 12 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod json; 3 | mod params; 4 | pub mod query_parser; 5 | mod serde; 6 | mod upload_file; 7 | mod value; 8 | 9 | pub use error::*; 10 | pub use json::*; 11 | pub use params::*; 12 | pub use serde::*; 13 | pub use upload_file::*; 14 | pub use value::*; 15 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 70% 6 | threshold: 0% 7 | if_not_found: success 8 | if_ci_failed: error 9 | 10 | patch: 11 | default: 12 | target: 70% 13 | threshold: 0% 14 | if_no_uploads: success 15 | if_not_found: success 16 | if_ci_failed: error 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-added-large-files 8 | - id: check-merge-conflict 9 | 10 | - repo: https://github.com/doublify/pre-commit-rust 11 | rev: v1.0 12 | hooks: 13 | - id: fmt 14 | - id: cargo-check 15 | - id: clippy 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | labels: 9 | - "dependencies" 10 | - "rust" 11 | 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | open-pull-requests-limit: 5 17 | labels: 18 | - "dependencies" 19 | - "github-actions" 20 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | }; 5 | 6 | #[derive(Debug, Clone)] 7 | pub enum Error { 8 | DecodeError(String), 9 | ReadError(String), 10 | IOError(String), 11 | MergeError(String), 12 | } 13 | 14 | impl IntoResponse for Error { 15 | fn into_response(self) -> Response { 16 | Response::builder() 17 | .status(StatusCode::BAD_REQUEST) 18 | .body(format!("{:?}", self).into()) 19 | .unwrap() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/upload_file.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use tokio::fs::File; 3 | 4 | #[derive(Debug, Serialize, Deserialize, Clone)] 5 | pub struct UploadFile { 6 | pub name: String, 7 | pub content_type: String, 8 | pub(crate) temp_file_path: String, 9 | } 10 | 11 | impl PartialEq for UploadFile { 12 | fn eq(&self, other: &Self) -> bool { 13 | self.name == other.name && self.content_type == other.content_type 14 | } 15 | } 16 | 17 | impl UploadFile { 18 | pub fn open(&self) -> impl std::future::Future> + '_ { 19 | File::open(&self.temp_file_path) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Li Jie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-params" 3 | version = "0.4.1" 4 | edition = "2024" 5 | license = "MIT" 6 | description = "A Rails-like powerful parameter handling library for Axum" 7 | repository = "https://github.com/cpunion/axum-params" 8 | keywords = ["axum", "params", "serde", "rails"] 9 | categories = ["web-programming"] 10 | 11 | [dependencies] 12 | actson = "2.0.1" 13 | axum = { version = "0.8.3", features = ["multipart", "macros"] } 14 | axum-macros = "0.5.0" 15 | form_urlencoded = "1.2.1" 16 | log = "0.4.27" 17 | multer = "3.0.0" 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0.140" 20 | serde_urlencoded = "0.7.1" 21 | tempfile = "3.19.1" 22 | tokio = { version = "1.44.2", features = ["full"] } 23 | url = "2.5.4" 24 | 25 | [dev-dependencies] 26 | axum-test = "17.3.0" 27 | env_logger = "0.11.8" 28 | futures-util = "0.3.29" 29 | maplit = "1.0.2" 30 | pretty_assertions = "1.4.0" 31 | serde_json = "1.0.140" 32 | 33 | [[example]] 34 | name = "basic_params" 35 | path = "examples/basic_params.rs" 36 | 37 | [[example]] 38 | name = "file_upload" 39 | path = "examples/file_upload.rs" 40 | 41 | [[example]] 42 | name = "nested_params" 43 | path = "examples/nested_params.rs" 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: Swatinem/rust-cache@v2 19 | continue-on-error: true 20 | - uses: dtolnay/rust-toolchain@stable 21 | with: 22 | components: llvm-tools-preview 23 | - name: Install cargo-llvm-cov 24 | uses: taiki-e/install-action@cargo-llvm-cov 25 | - name: Generate code coverage 26 | run: cargo llvm-cov --all-features --codecov --output-path coverage.json 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v5.4.2 29 | with: 30 | files: coverage.json 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | fail_ci_if_error: true 33 | 34 | clippy: 35 | name: Clippy 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: dtolnay/rust-toolchain@stable 40 | with: 41 | components: clippy 42 | - uses: Swatinem/rust-cache@v2 43 | - name: Clippy check 44 | run: cargo clippy --all-features -- -D warnings 45 | 46 | rustfmt: 47 | name: Format 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: dtolnay/rust-toolchain@stable 52 | with: 53 | components: rustfmt 54 | - name: Check code formatting 55 | run: cargo fmt --all -- --check 56 | 57 | docs: 58 | name: Docs 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: dtolnay/rust-toolchain@stable 63 | - uses: Swatinem/rust-cache@v2 64 | - name: Check documentation 65 | env: 66 | RUSTDOCFLAGS: -D warnings 67 | run: cargo doc --no-deps --all-features 68 | -------------------------------------------------------------------------------- /examples/basic_params.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post}; 2 | use axum_params::Params; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | // Simple parameters with path, query, and optional fields 6 | #[derive(Debug, Deserialize, Serialize)] 7 | struct TestParams { 8 | id: i32, // Path parameter (/users/:id) 9 | name: String, // From JSON or form 10 | #[serde(default)] 11 | extra: Option, // Optional query parameter 12 | } 13 | 14 | #[axum::debug_handler] 15 | async fn test_params_handler(Params(test, _): Params) -> impl IntoResponse { 16 | // Access parameters naturally 17 | println!("ID: {}, Name: {}", test.id, test.name); 18 | (StatusCode::OK, Json(test)) 19 | } 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | // Build our application with a route 24 | let app = Router::new().route("/users/:id", post(test_params_handler)); 25 | 26 | // Run it 27 | let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") 28 | .await 29 | .unwrap(); 30 | println!("listening on {}", listener.local_addr().unwrap()); 31 | axum::serve(listener, app).await.unwrap(); 32 | } 33 | 34 | /* 35 | Test with curl: 36 | 37 | # Combined path, query, and JSON parameters 38 | curl -X POST "http://localhost:3000/users/123?extra=additional" \ 39 | -H "Content-Type: application/json" \ 40 | -d '{"name": "John Doe"}' 41 | 42 | Expected response: 43 | { 44 | "id": 123, 45 | "name": "John Doe", 46 | "extra": "additional" 47 | } 48 | 49 | # Form data instead of JSON 50 | curl -X POST "http://localhost:3000/users/123?extra=additional" \ 51 | -H "Content-Type: application/x-www-form-urlencoded" \ 52 | -d "name=John%20Doe" 53 | 54 | # Test using this source file as form data 55 | curl -X POST "http://localhost:3000/users/123?extra=additional" \ 56 | -H "Content-Type: application/x-www-form-urlencoded" \ 57 | --data-urlencode "name@examples/basic_params.rs" 58 | */ 59 | -------------------------------------------------------------------------------- /examples/file_upload.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, response::IntoResponse, routing::post}; 2 | use axum_params::{Params, UploadFile}; 3 | use serde::Deserialize; 4 | use serde_json::json; 5 | use tokio::io::AsyncReadExt; 6 | 7 | // File upload with metadata 8 | #[derive(Debug, Deserialize)] 9 | struct FileUploadParams { 10 | title: String, 11 | description: Option, 12 | file: UploadFile, 13 | } 14 | 15 | #[axum::debug_handler] 16 | async fn file_upload_handler(Params(upload, _): Params) -> impl IntoResponse { 17 | let mut temp_file = upload.file.open().await.unwrap(); 18 | let mut content = String::new(); 19 | temp_file.read_to_string(&mut content).await.unwrap(); 20 | 21 | Json(json!({ 22 | "title": upload.title, 23 | "description": upload.description, 24 | "file_name": upload.file.name, 25 | "file_content": content, 26 | })) 27 | } 28 | 29 | #[tokio::main] 30 | async fn main() { 31 | // Build our application with a route 32 | let app = Router::new().route("/upload", post(file_upload_handler)); 33 | 34 | // Run it 35 | let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") 36 | .await 37 | .unwrap(); 38 | println!("listening on {}", listener.local_addr().unwrap()); 39 | axum::serve(listener, app).await.unwrap(); 40 | } 41 | 42 | /* 43 | Test with curl: 44 | 45 | # Upload this source file with metadata using multipart form 46 | curl -X POST http://localhost:3000/upload \ 47 | -F "title=File Upload Example" \ 48 | -F "description=Source code of the file upload example" \ 49 | -F "file=@examples/file_upload.rs" 50 | 51 | Expected response: 52 | { 53 | "title": "File Upload Example", 54 | "description": "Source code of the file upload example", 55 | "file_name": "file_upload.rs", 56 | "file_content": "... content of this file ..." 57 | } 58 | 59 | # Upload without optional description 60 | curl -X POST http://localhost:3000/upload \ 61 | -F "title=File Upload Example" \ 62 | -F "file=@examples/file_upload.rs" 63 | */ 64 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.4.0 (2025-03-03) 4 | 5 | ### Changes 6 | - Upgrade to axum@0.8.1 7 | - Upgrade to Rust Edition 2024 8 | - Update dependencies 9 | - Remove unused trait ParamsReader 10 | 11 | ## v0.3.0 (2025-01-02) 12 | 13 | ### Breaking Changes 14 | - Process JSON escaped characters correctly in JSON parser 15 | - Now properly handles all standard JSON escape sequences (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) 16 | - Fixed handling of Unicode escape sequences 17 | - Improved error handling for invalid escape sequences 18 | 19 | ## v0.2.0 (2024-12-29) 20 | 21 | ### Changes 22 | - Port [rack/query_parser.rb](https://github.com/rack/rack/blob/main/lib/rack/query_parser.rb) to make better compatible with rails/rack 23 | - Port [rack/spec_utils.rb](https://github.com/rack/rack/blob/main/test/spec_utils.rb) 24 | - Merge JSON with other structured data 25 | - Rename `axum_params::ParamsValue` to `axum_params::Value`, doesn't need use it directly 26 | 27 | ## v0.1.0 (2024-12-25) 28 | 29 | ### Features 30 | 31 | #### Unified Parameter Handling 32 | - Path parameters 33 | - Query parameters 34 | - Form data 35 | - Multipart form data 36 | - JSON body 37 | - All parameter types can be processed simultaneously 38 | - Every parameter type supports structured data (arrays and objects) 39 | 40 | #### Rails-like Tree-Structured Parameters 41 | - Nested parameter handling similar to Rails' strong parameters 42 | - Support for deeply nested structures with arrays and objects 43 | - Files can be placed at any position in the parameter tree, e.g. `post[attachments][][file]` 44 | - Seamlessly mix files with other data types in the same request 45 | - Automatic parameter parsing and type conversion 46 | - Handle complex forms with multiple file uploads in nested structures 47 | 48 | Example structure: 49 | ```rust 50 | post: { 51 | title: String, 52 | content: String, 53 | tags: Vec, 54 | cover: UploadFile, 55 | attachments: Vec<{ 56 | file: UploadFile, 57 | description: String 58 | }> 59 | } 60 | ``` 61 | -------------------------------------------------------------------------------- /examples/nested_params.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, routing::post}; 2 | use axum_params::{Error, Params, UploadFile}; 3 | use futures_util::future; 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::io::AsyncReadExt; 6 | 7 | // Complex nested structure with multiple files 8 | #[derive(Debug, Serialize, Deserialize)] 9 | struct CreatePostParams { 10 | title: String, 11 | content: String, 12 | tags: Vec, 13 | cover: UploadFile, // Single file 14 | attachments: Vec, // Array of files with metadata 15 | } 16 | 17 | #[derive(Debug, Serialize, Deserialize)] 18 | struct Attachment { 19 | file: UploadFile, 20 | name: String, 21 | } 22 | 23 | #[derive(Debug, Serialize, Deserialize)] 24 | struct AttachmentResponse { 25 | name: String, 26 | content_type: String, 27 | content: String, 28 | } 29 | 30 | #[derive(Debug, Serialize, Deserialize)] 31 | struct UploadFileResponse { 32 | name: String, 33 | content_type: String, 34 | content: String, 35 | } 36 | 37 | #[derive(Debug, Serialize, Deserialize)] 38 | struct CreatePostResponse { 39 | title: String, 40 | content: String, 41 | tags: Vec, 42 | cover: UploadFileResponse, 43 | attachments: Vec, 44 | } 45 | 46 | #[axum::debug_handler] 47 | async fn create_post_handler( 48 | Params(post, _): Params, 49 | ) -> Result, Error> { 50 | // Handle cover file 51 | let mut cover_file = post.cover.open().await.unwrap(); 52 | let mut cover_content = String::new(); 53 | cover_file.read_to_string(&mut cover_content).await.unwrap(); 54 | 55 | // Handle attachments 56 | let attachments = future::join_all(post.attachments.into_iter().map(|a| async { 57 | let mut file = a.file.open().await.unwrap(); 58 | let mut content = String::new(); 59 | file.read_to_string(&mut content).await.unwrap(); 60 | 61 | AttachmentResponse { 62 | name: a.name, 63 | content_type: a.file.content_type, 64 | content, 65 | } 66 | })) 67 | .await; 68 | 69 | Ok(Json(CreatePostResponse { 70 | title: post.title, 71 | content: post.content, 72 | tags: post.tags, 73 | cover: UploadFileResponse { 74 | name: post.cover.name, 75 | content_type: post.cover.content_type, 76 | content: cover_content, 77 | }, 78 | attachments, 79 | })) 80 | } 81 | 82 | #[tokio::main] 83 | async fn main() { 84 | // Build our application with a route 85 | let app = Router::new().route("/posts", post(create_post_handler)); 86 | 87 | // Run it 88 | let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") 89 | .await 90 | .unwrap(); 91 | println!("listening on {}", listener.local_addr().unwrap()); 92 | axum::serve(listener, app).await.unwrap(); 93 | } 94 | 95 | /* 96 | Test with curl: 97 | 98 | # Create post using form fields 99 | curl -X POST http://localhost:3000/posts \ 100 | -F "title=Rust Examples" \ 101 | -F "content=Collection of axum-params examples" \ 102 | -F "tags[]=rust" \ 103 | -F "tags[]=axum" \ 104 | -F "cover=@examples/nested_params.rs" \ 105 | -F "attachments[0][name]=Basic Params" \ 106 | -F "attachments[0][file]=@examples/basic_params.rs" \ 107 | -F "attachments[1][name]=File Upload" \ 108 | -F "attachments[1][file]=@examples/file_upload.rs" 109 | 110 | # Or create post using JSON file for metadata and -F for files 111 | # First, create a temporary JSON file: 112 | cat > /tmp/post.json << 'EOF' 113 | { 114 | "title": "Rust Examples", 115 | "content": "Collection of axum-params examples", 116 | "tags": ["rust", "axum"] 117 | } 118 | EOF 119 | 120 | # Then use the JSON file in the request 121 | curl -X POST http://localhost:3000/posts \ 122 | -F "=@/tmp/post.json;type=application/json" \ 123 | -F "cover=@examples/nested_params.rs" \ 124 | -F "attachments[0][name]=Basic Params" \ 125 | -F "attachments[0][file]=@examples/basic_params.rs" \ 126 | -F "attachments[1][name]=File Upload" \ 127 | -F "attachments[1][file]=@examples/file_upload.rs" 128 | 129 | # Clean up 130 | rm /tmp/post.json 131 | 132 | Expected response: 133 | { 134 | "title": "Rust Examples", 135 | "content": "Collection of axum-params examples", 136 | "tags": ["rust", "axum"], 137 | "cover": { 138 | "name": "nested_params.rs", 139 | "content_type": "text/x-rust", 140 | "content": "... content of nested_params.rs ..." 141 | }, 142 | "attachments": [ 143 | { 144 | "name": "Basic Params", 145 | "content_type": "text/x-rust", 146 | "content": "... content of basic_params.rs ..." 147 | }, 148 | { 149 | "name": "File Upload", 150 | "content_type": "text/x-rust", 151 | "content": "... content of file_upload.rs ..." 152 | } 153 | ] 154 | } 155 | */ 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Axum Params 2 | 3 | A powerful parameter handling library for [Axum](https://github.com/tokio-rs/axum) web framework, inspired by [Ruby on Rails](https://rubyonrails.org/)' parameter system. Seamlessly handles multiple parameter sources and tree-structured data with file uploads. 4 | 5 | 6 | [![Build Status](https://github.com/cpunion/axum-params/actions/workflows/ci.yml/badge.svg)](https://github.com/cpunion/axum-params/actions/workflows/ci.yml) 7 | [![codecov](https://codecov.io/github/cpunion/axum-params/graph/badge.svg?token=uATQa0RzPL)](https://codecov.io/github/cpunion/axum-params) 8 | [![crate](https://img.shields.io/crates/v/axum-params.svg)](https://crates.io/crates/axum-params) 9 | [![docs](https://docs.rs/axum-params/badge.svg)](https://docs.rs/axum-params) 10 | [![GitHub commits](https://badgen.net/github/commits/cpunion/axum-params)](https://GitHub.com/Naereen/cpunion/axum-params/commit/) 11 | [![GitHub release](https://img.shields.io/github/v/tag/cpunion/axum-params.svg?label=release)](https://github.com/cpunion/axum-params/releases) 12 | 13 | 14 | ## Features 15 | 16 | - **Unified Parameter Handling** 17 | - Path parameters 18 | - Query parameters 19 | - Form data 20 | - Multipart form data 21 | - JSON body 22 | - All parameter types can be processed simultaneously 23 | - Every parameter type supports structured data (arrays and objects) 24 | 25 | - **Rails-like Tree-Structured Parameters** 26 | - Nested parameter handling similar to Rails' strong parameters 27 | - Support for deeply nested structures with arrays and objects 28 | - Files can be placed at any position in the parameter tree, e.g. `post[attachments][][file]` 29 | - Seamlessly mix files with other data types in the same request 30 | - Automatic parameter parsing and type conversion 31 | - Handle complex forms with multiple file uploads in nested structures 32 | 33 | Example structure: 34 | 35 | ```rust 36 | post: { 37 | title: String, 38 | content: String, 39 | tags: Vec, 40 | cover: UploadFile, 41 | attachments: Vec<{ 42 | file: UploadFile, 43 | description: String 44 | }> 45 | } 46 | ``` 47 | 48 | 49 | ## Installation 50 | 51 | Add this to your `Cargo.toml`: 52 | 53 | ```toml 54 | [dependencies] 55 | axum-params = "0.4" 56 | ``` 57 | 58 | ## Quick Start 59 | 60 | ```rust 61 | use axum::{routing::post, Router}; 62 | use axum_params::Params; 63 | use serde::{Deserialize, Serialize}; 64 | 65 | #[derive(Serialize, Deserialize)] 66 | struct Attachment { 67 | file: UploadFile, 68 | description: String, 69 | } 70 | 71 | #[derive(Serialize, Deserialize)] 72 | struct CreatePost { 73 | title: String, 74 | content: String, 75 | tags: Vec, 76 | cover: UploadFile, 77 | attachments: Vec, 78 | } 79 | 80 | #[debug_handler] 81 | async fn create_post_handler(Params(post, _): Params) -> impl IntoResponse { 82 | // Handle cover file 83 | let mut cover_file = post.cover.open().await.unwrap(); 84 | // process file 85 | // Handle attachments 86 | for attachment in post.attachments { 87 | let mut file = attachment.file.open().await.unwrap(); 88 | // process file 89 | } 90 | } 91 | ``` 92 | 93 | ## Rails-like Parameter Structure 94 | 95 | Just like Rails, you can send nested parameters in various formats: 96 | 97 | ### Combined Parameters Example 98 | ```bash 99 | # Combining path parameters, query parameters, and form data 100 | curl -X POST "http://localhost:3000/posts/123?draft=true" \ 101 | -F "post[title]=My First Post" \ 102 | -F "post[content]=Hello World from Axum" \ 103 | -F "post[tags][]=rust" \ 104 | -F "post[cover]=@cover.jpg" 105 | ``` 106 | 107 | ### JSON Body 108 | ```bash 109 | # Sending JSON data (note: file uploads not possible in pure JSON) 110 | curl -X POST http://localhost:3000/posts \ 111 | -H "Content-Type: application/json" \ 112 | -d '{ 113 | "post": { 114 | "title": "My First Post", 115 | "content": "Hello World from Axum", 116 | "tags": ["rust", "web", "axum"] 117 | } 118 | }' 119 | ``` 120 | 121 | ### Form Data 122 | ```bash 123 | # Basic form data with nested parameters and file uploads 124 | curl -X POST http://localhost:3000/posts \ 125 | -F "post[title]=My First Post" \ 126 | -F "post[content]=Hello World from Axum" \ 127 | -F "post[tags][]=rust" \ 128 | -F "post[tags][]=web" \ 129 | -F "post[tags][]=axum" \ 130 | -F "post[cover]=@cover.jpg" \ 131 | -F "post[attachments][][file]=@document.pdf" \ 132 | -F "post[attachments][][description]=Project Documentation" \ 133 | -F "post[attachments][][file]=@diagram.png" \ 134 | -F "post[attachments][][description]=Architecture Diagram" 135 | ``` 136 | 137 | ### Multipart Form 138 | The library automatically handles multipart form data, allowing you to upload files within nested structures. Files can be placed at any level in the parameter tree, and you can combine them with regular form fields. 139 | 140 | ```bash 141 | # Complex multipart form matching the Post struct example 142 | curl -X POST http://localhost:3000/posts \ 143 | -F "post[title]=My First Post" \ 144 | -F "post[content]=Hello World from Axum" \ 145 | -F "post[tags][]=rust" \ 146 | -F "post[tags][]=web" \ 147 | -F "post[tags][]=axum" \ 148 | -F "post[cover]=@cover.jpg" \ 149 | -F "post[attachments][][file]=@document.pdf" \ 150 | -F "post[attachments][][description]=Project Documentation" \ 151 | -F "post[attachments][][file]=@diagram.png" \ 152 | -F "post[attachments][][description]=Architecture Diagram" \ 153 | -F "post[attachments][][file]=@screenshot.jpg" \ 154 | -F "post[attachments][][description]=Application Screenshot" 155 | ``` 156 | 157 | This example demonstrates how the multipart form maps to the Rust struct: 158 | - Single field (`title`, `content`) 159 | - Array field (`tags[]`) 160 | - Single file field (`cover`) 161 | - Nested array with files (`attachments[]` with `file` and `description`) 162 | 163 | ## Examples 164 | 165 | - [Basic Parameters](examples/basic_params.rs) - Handling path, query, and JSON parameters 166 | - [File Upload](examples/file_upload.rs) - Basic file upload with metadata 167 | - [Nested Parameters](examples/nested_params.rs) - Complex nested structures with multiple file uploads 168 | 169 | ### Running Examples 170 | 171 | ```bash 172 | # Run basic parameters example 173 | cargo run --example basic_params 174 | 175 | # Run file upload example 176 | cargo run --example file_upload 177 | 178 | # Run nested parameters example 179 | cargo run --example nested_params 180 | ``` 181 | 182 | ## Contributing 183 | 184 | Contributions are welcome! Please feel free to submit a Pull Request. 185 | 186 | ## Acknowledgments 187 | 188 | - The parameter parsing implementation is ported from [Rack's QueryParser](https://github.com/rack/rack), which provides robust and battle-tested parameter parsing capabilities. 189 | - This project draws inspiration from Ruby on Rails' parameter handling system, adapting its elegant approach to parameter processing for the Rust and Axum ecosystem. 190 | 191 | ## License 192 | 193 | This project is licensed under the MIT License - see the LICENSE file for details. 194 | -------------------------------------------------------------------------------- /src/value.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{Error, UploadFile}; 4 | 5 | #[derive(Debug, Copy, Clone, PartialEq)] 6 | pub(crate) enum N { 7 | PosInt(u64), 8 | /// Always less than zero. 9 | NegInt(i64), 10 | /// Always finite. 11 | Float(f64), 12 | } 13 | 14 | #[derive(Debug, Copy, Clone, PartialEq)] 15 | pub struct Number(pub(crate) N); 16 | 17 | impl From for Number { 18 | fn from(v: u64) -> Self { 19 | Number(N::PosInt(v)) 20 | } 21 | } 22 | 23 | impl From for Number { 24 | fn from(v: i64) -> Self { 25 | if v >= 0 { 26 | Number(N::PosInt(v as u64)) 27 | } else { 28 | Number(N::NegInt(v)) 29 | } 30 | } 31 | } 32 | 33 | impl From for Number { 34 | fn from(v: f64) -> Self { 35 | Number(N::Float(v)) 36 | } 37 | } 38 | 39 | pub trait IntoNumber { 40 | fn into_number(self) -> Number; 41 | } 42 | 43 | impl IntoNumber for u64 { 44 | fn into_number(self) -> Number { 45 | Number::from(self) 46 | } 47 | } 48 | 49 | impl IntoNumber for i64 { 50 | fn into_number(self) -> Number { 51 | Number::from(self) 52 | } 53 | } 54 | 55 | impl IntoNumber for f64 { 56 | fn into_number(self) -> Number { 57 | Number::from(self) 58 | } 59 | } 60 | 61 | #[derive(Debug, Clone)] 62 | pub enum Value { 63 | Null, 64 | Bool(bool), 65 | Number(Number), 66 | String(String), 67 | XStr(String), 68 | Object(HashMap), 69 | Array(Vec), 70 | UploadFile(UploadFile), 71 | } 72 | 73 | impl PartialEq for Value { 74 | fn eq(&self, other: &Self) -> bool { 75 | match (self, other) { 76 | (Self::Null, Self::Null) => true, 77 | (Self::XStr(a), b) => match b { 78 | Self::XStr(b) => a == b, 79 | Self::String(b) => a == b, 80 | _ => false, 81 | }, 82 | (a, Self::XStr(b)) => match a { 83 | Self::XStr(a) => a == b, 84 | Self::String(a) => a == b, 85 | _ => false, 86 | }, 87 | (Self::Bool(a), Self::Bool(b)) => a == b, 88 | (Self::Number(a), Self::Number(b)) => a == b, 89 | (Self::String(a), Self::String(b)) => a == b, 90 | (Self::Object(a), Self::Object(b)) => a == b, 91 | (Self::Array(a), Self::Array(b)) => a == b, 92 | (Self::UploadFile(a), Self::UploadFile(b)) => a == b, 93 | _ => false, 94 | } 95 | } 96 | } 97 | 98 | impl Value { 99 | pub fn merge(self, other: Value) -> Result { 100 | match (self, other) { 101 | // Object + Object = Merged object 102 | (Value::Object(mut a), Value::Object(b)) => { 103 | a.extend(b); 104 | Ok(Value::Object(a)) 105 | } 106 | // Array + Array = Combined array 107 | (Value::Array(mut a), Value::Array(b)) => { 108 | a.extend(b); 109 | Ok(Value::Array(a)) 110 | } 111 | // Array + Any = Array with new element 112 | (Value::Array(mut a), other) => { 113 | a.push(other); 114 | Ok(Value::Array(a)) 115 | } 116 | // Any + Array = Array with new element at start 117 | (value, Value::Array(mut arr)) => { 118 | arr.insert(0, value); 119 | Ok(Value::Array(arr)) 120 | } 121 | // Null + Any = Any 122 | (Value::Null, other) => Ok(other), 123 | // Any + Null = Any 124 | (value, Value::Null) => Ok(value), 125 | // Incompatible types 126 | (a, b) => Err(Error::MergeError(format!( 127 | "Cannot merge {} with {}", 128 | a.type_name(), 129 | b.type_name() 130 | ))), 131 | } 132 | } 133 | 134 | pub fn merge_into( 135 | self, 136 | mut a: HashMap, 137 | ) -> Result, Error> { 138 | match self { 139 | Value::Object(b) => { 140 | a.extend(b); 141 | Ok(a) 142 | } 143 | _ => Err(Error::MergeError(format!( 144 | "Cannot merge {} with object", 145 | self.type_name() 146 | ))), 147 | } 148 | } 149 | 150 | pub fn xstr>(v: T) -> Value { 151 | Value::XStr(v.into()) 152 | } 153 | 154 | pub fn xstr_opt>(v: Option) -> Value { 155 | match v { 156 | Some(v) => Value::XStr(v.into()), 157 | None => Value::Null, 158 | } 159 | } 160 | 161 | pub fn number(v: T) -> Value { 162 | Value::Number(v.into_number()) 163 | } 164 | 165 | pub fn bool(v: bool) -> Value { 166 | Value::Bool(v) 167 | } 168 | 169 | pub fn null() -> Value { 170 | Value::Null 171 | } 172 | 173 | pub fn array(v: Vec) -> Value { 174 | Value::Array(v) 175 | } 176 | 177 | pub fn object(v: HashMap) -> Value { 178 | Value::Object(v) 179 | } 180 | 181 | pub fn type_name(&self) -> &'static str { 182 | match self { 183 | Value::Null => "null", 184 | Value::Bool(_) => "bool", 185 | Value::Number(_) => "number", 186 | Value::String(_) => "string", 187 | Value::Object(_) => "object", 188 | Value::Array(_) => "array", 189 | Value::XStr(_) => "string", 190 | Value::UploadFile(_) => "file", 191 | } 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | mod tests { 197 | use super::*; 198 | 199 | #[test] 200 | fn test_number_from_u64() { 201 | let n = Number::from(42u64); 202 | assert!(matches!(n.0, N::PosInt(42))); 203 | 204 | let n = Number::from(0u64); 205 | assert!(matches!(n.0, N::PosInt(0))); 206 | 207 | let n = Number::from(u64::MAX); 208 | assert!(matches!(n.0, N::PosInt(u64::MAX))); 209 | } 210 | 211 | #[test] 212 | fn test_number_from_i64() { 213 | let n = Number::from(42i64); 214 | assert!(matches!(n.0, N::PosInt(42))); 215 | 216 | let n = Number::from(0i64); 217 | assert!(matches!(n.0, N::PosInt(0))); 218 | 219 | let n = Number::from(-42i64); 220 | assert!(matches!(n.0, N::NegInt(-42))); 221 | 222 | let n = Number::from(i64::MIN); 223 | assert!(matches!(n.0, N::NegInt(i64::MIN))); 224 | } 225 | 226 | #[test] 227 | fn test_number_from_f64() { 228 | let n = Number::from(42.0); 229 | assert!(matches!(n.0, N::Float(v) if v == 42.0)); 230 | 231 | let n = Number::from(0.0); 232 | assert!(matches!(n.0, N::Float(v) if v == 0.0)); 233 | 234 | let n = Number::from(-42.5); 235 | assert!(matches!(n.0, N::Float(v) if v == -42.5)); 236 | 237 | let n = Number::from(f64::MIN_POSITIVE); 238 | assert!(matches!(n.0, N::Float(v) if v == f64::MIN_POSITIVE)); 239 | 240 | let n = Number::from(f64::MAX); 241 | assert!(matches!(n.0, N::Float(v) if v == f64::MAX)); 242 | } 243 | 244 | #[test] 245 | fn test_number_equality() { 246 | // Same type comparisons 247 | assert_eq!(Number::from(42u64), Number::from(42u64)); 248 | assert_eq!(Number::from(-42i64), Number::from(-42i64)); 249 | assert_eq!(Number::from(42.0), Number::from(42.0)); 250 | 251 | // Different values 252 | assert_ne!(Number::from(42u64), Number::from(43u64)); 253 | assert_ne!(Number::from(-42i64), Number::from(-43i64)); 254 | assert_ne!(Number::from(42.0), Number::from(42.5)); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/serde.rs: -------------------------------------------------------------------------------- 1 | use crate::{N, Number}; 2 | 3 | use super::Value; 4 | use log::debug; 5 | use serde::{ 6 | Deserialize, Deserializer, 7 | de::{self, MapAccess, SeqAccess, Visitor}, 8 | }; 9 | use std::collections::HashMap; 10 | 11 | struct ParamsValueVisitor; 12 | 13 | impl<'de> Visitor<'de> for ParamsValueVisitor { 14 | type Value = Value; 15 | 16 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 17 | formatter.write_str("any valid JSON value or upload file") 18 | } 19 | 20 | fn visit_bool(self, v: bool) -> Result { 21 | Ok(Value::Bool(v)) 22 | } 23 | 24 | fn visit_i64(self, v: i64) -> Result { 25 | Ok(Value::Number(Number::from(v))) 26 | } 27 | 28 | fn visit_u64(self, v: u64) -> Result { 29 | Ok(Value::Number(Number::from(v))) 30 | } 31 | 32 | fn visit_f64(self, v: f64) -> Result { 33 | Ok(Value::Number(Number::from(v))) 34 | } 35 | 36 | fn visit_str(self, v: &str) -> Result 37 | where 38 | E: de::Error, 39 | { 40 | Ok(Value::XStr(v.to_owned())) 41 | } 42 | 43 | fn visit_string(self, v: String) -> Result { 44 | Ok(Value::XStr(v)) 45 | } 46 | 47 | fn visit_none(self) -> Result { 48 | Ok(Value::Null) 49 | } 50 | 51 | fn visit_some(self, deserializer: D) -> Result 52 | where 53 | D: serde::Deserializer<'de>, 54 | { 55 | Deserialize::deserialize(deserializer) 56 | } 57 | 58 | fn visit_unit(self) -> Result { 59 | Ok(Value::Null) 60 | } 61 | 62 | fn visit_seq(self, mut seq: A) -> Result 63 | where 64 | A: de::SeqAccess<'de>, 65 | { 66 | let mut vec = Vec::new(); 67 | while let Some(elem) = seq.next_element()? { 68 | vec.push(elem); 69 | } 70 | Ok(Value::Array(vec)) 71 | } 72 | 73 | fn visit_map(self, mut map: A) -> Result 74 | where 75 | A: de::MapAccess<'de>, 76 | { 77 | let mut values = HashMap::new(); 78 | while let Some((key, value)) = map.next_entry()? { 79 | values.insert(key, value); 80 | } 81 | Ok(Value::Object(values)) 82 | } 83 | } 84 | 85 | impl<'de> Deserialize<'de> for Value { 86 | fn deserialize(deserializer: D) -> Result 87 | where 88 | D: serde::Deserializer<'de>, 89 | { 90 | deserializer.deserialize_any(ParamsValueVisitor) 91 | } 92 | } 93 | 94 | struct MapAccessor { 95 | map: std::collections::hash_map::IntoIter, 96 | current_value: Option, 97 | } 98 | 99 | impl MapAccessor { 100 | fn new(map: HashMap) -> Self { 101 | MapAccessor { 102 | map: map.into_iter(), 103 | current_value: None, 104 | } 105 | } 106 | } 107 | 108 | impl<'de> MapAccess<'de> for MapAccessor { 109 | type Error = serde::de::value::Error; 110 | 111 | fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> 112 | where 113 | K: de::DeserializeSeed<'de>, 114 | { 115 | match self.map.next() { 116 | Some((key, value)) => { 117 | self.current_value = Some(value); 118 | seed.deserialize(key.into_deserializer()).map(Some) 119 | } 120 | None => Ok(None), 121 | } 122 | } 123 | 124 | fn next_value_seed(&mut self, seed: V) -> Result 125 | where 126 | V: de::DeserializeSeed<'de>, 127 | { 128 | match self.current_value.take() { 129 | Some(value) => seed.deserialize(value), 130 | None => Err(de::Error::custom("value is missing")), 131 | } 132 | } 133 | } 134 | 135 | struct SeqAccessor { 136 | seq: std::vec::IntoIter, 137 | } 138 | 139 | impl<'de> SeqAccess<'de> for SeqAccessor { 140 | type Error = serde::de::value::Error; 141 | 142 | fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> 143 | where 144 | T: de::DeserializeSeed<'de>, 145 | { 146 | match self.seq.next() { 147 | Some(value) => seed.deserialize(value).map(Some), 148 | None => Ok(None), 149 | } 150 | } 151 | } 152 | 153 | impl<'de> Deserializer<'de> for Value { 154 | type Error = serde::de::value::Error; 155 | 156 | fn deserialize_any(self, visitor: V) -> Result 157 | where 158 | V: Visitor<'de>, 159 | { 160 | match self { 161 | Value::Null => visitor.visit_unit(), 162 | Value::Bool(b) => visitor.visit_bool(b), 163 | Value::Number(Number(n)) => match n { 164 | N::PosInt(i) => visitor.visit_u64(i), 165 | N::NegInt(i) => visitor.visit_i64(i), 166 | N::Float(f) => visitor.visit_f64(f), 167 | }, 168 | Value::String(s) => visitor.visit_string(s), 169 | Value::Object(map) => visitor.visit_map(MapAccessor::new(map)), 170 | Value::Array(vec) => visitor.visit_seq(SeqAccessor { 171 | seq: vec.into_iter(), 172 | }), 173 | Value::XStr(s) => visitor.visit_string(s), 174 | Value::UploadFile(file) => { 175 | let map = HashMap::from([ 176 | ("name".to_string(), Value::String(file.name.clone())), 177 | ( 178 | "content_type".to_string(), 179 | Value::String(file.content_type.clone()), 180 | ), 181 | ( 182 | "temp_file_path".to_string(), 183 | Value::String(file.temp_file_path.to_string()), 184 | ), 185 | ]); 186 | visitor.visit_map(MapAccessor::new(map)) 187 | } 188 | } 189 | } 190 | 191 | fn deserialize_bool(self, visitor: V) -> Result 192 | where 193 | V: Visitor<'de>, 194 | { 195 | match self { 196 | Value::XStr(s) => match s.to_lowercase().as_str() { 197 | "true" | "1" | "on" | "yes" => visitor.visit_bool(true), 198 | "false" | "0" | "off" | "no" => visitor.visit_bool(false), 199 | _ => Err(de::Error::custom("invalid boolean value")), 200 | }, 201 | _ => self.deserialize_any(visitor), 202 | } 203 | } 204 | 205 | fn deserialize_i8(self, visitor: V) -> Result 206 | where 207 | V: Visitor<'de>, 208 | { 209 | match self { 210 | Value::XStr(s) => s 211 | .parse() 212 | .map_err(de::Error::custom) 213 | .and_then(|v| visitor.visit_i8(v)), 214 | _ => self.deserialize_any(visitor), 215 | } 216 | } 217 | 218 | fn deserialize_i16(self, visitor: V) -> Result 219 | where 220 | V: Visitor<'de>, 221 | { 222 | match self { 223 | Value::XStr(s) => s 224 | .parse() 225 | .map_err(de::Error::custom) 226 | .and_then(|v| visitor.visit_i16(v)), 227 | _ => self.deserialize_any(visitor), 228 | } 229 | } 230 | 231 | fn deserialize_i32(self, visitor: V) -> Result 232 | where 233 | V: Visitor<'de>, 234 | { 235 | match self { 236 | Value::XStr(s) => s 237 | .parse() 238 | .map_err(de::Error::custom) 239 | .and_then(|v| visitor.visit_i32(v)), 240 | _ => self.deserialize_any(visitor), 241 | } 242 | } 243 | 244 | fn deserialize_i64(self, visitor: V) -> Result 245 | where 246 | V: Visitor<'de>, 247 | { 248 | debug!("deserialize_i64 self: {:?}", self); 249 | match self { 250 | Value::XStr(s) => s 251 | .parse() 252 | .map_err(de::Error::custom) 253 | .and_then(|v| visitor.visit_i64(v)), 254 | _ => self.deserialize_any(visitor), 255 | } 256 | } 257 | 258 | fn deserialize_u8(self, visitor: V) -> Result 259 | where 260 | V: Visitor<'de>, 261 | { 262 | match self { 263 | Value::XStr(s) => s 264 | .parse() 265 | .map_err(de::Error::custom) 266 | .and_then(|v| visitor.visit_u8(v)), 267 | _ => self.deserialize_any(visitor), 268 | } 269 | } 270 | 271 | fn deserialize_u16(self, visitor: V) -> Result 272 | where 273 | V: Visitor<'de>, 274 | { 275 | match self { 276 | Value::XStr(s) => s 277 | .parse() 278 | .map_err(de::Error::custom) 279 | .and_then(|v| visitor.visit_u16(v)), 280 | _ => self.deserialize_any(visitor), 281 | } 282 | } 283 | 284 | fn deserialize_u32(self, visitor: V) -> Result 285 | where 286 | V: Visitor<'de>, 287 | { 288 | match self { 289 | Value::XStr(s) => s 290 | .parse() 291 | .map_err(de::Error::custom) 292 | .and_then(|v| visitor.visit_u32(v)), 293 | _ => self.deserialize_any(visitor), 294 | } 295 | } 296 | 297 | fn deserialize_u64(self, visitor: V) -> Result 298 | where 299 | V: Visitor<'de>, 300 | { 301 | match self { 302 | Value::XStr(s) => s 303 | .parse() 304 | .map_err(de::Error::custom) 305 | .and_then(|v| visitor.visit_u64(v)), 306 | _ => self.deserialize_any(visitor), 307 | } 308 | } 309 | 310 | fn deserialize_f32(self, visitor: V) -> Result 311 | where 312 | V: Visitor<'de>, 313 | { 314 | debug!("deserialize_f32 self: {:?}", self); 315 | match self { 316 | Value::XStr(s) => s 317 | .parse() 318 | .map_err(de::Error::custom) 319 | .and_then(|v| visitor.visit_f32(v)), 320 | _ => self.deserialize_any(visitor), 321 | } 322 | } 323 | 324 | fn deserialize_f64(self, visitor: V) -> Result 325 | where 326 | V: Visitor<'de>, 327 | { 328 | debug!("deserialize_f64 self: {:?}", self); 329 | match self { 330 | Value::XStr(s) => s 331 | .parse() 332 | .map_err(de::Error::custom) 333 | .and_then(|v| visitor.visit_f64(v)), 334 | _ => self.deserialize_any(visitor), 335 | } 336 | } 337 | 338 | fn deserialize_char(self, visitor: V) -> Result 339 | where 340 | V: Visitor<'de>, 341 | { 342 | match self { 343 | Value::XStr(s) => { 344 | let mut chars = s.chars(); 345 | match (chars.next(), chars.next()) { 346 | (Some(c), None) => visitor.visit_char(c), 347 | _ => Err(de::Error::custom("invalid char value")), 348 | } 349 | } 350 | _ => self.deserialize_any(visitor), 351 | } 352 | } 353 | 354 | fn deserialize_option(self, visitor: V) -> Result 355 | where 356 | V: Visitor<'de>, 357 | { 358 | match self { 359 | Value::Null => visitor.visit_none(), 360 | _ => visitor.visit_some(self), 361 | } 362 | } 363 | 364 | fn deserialize_enum( 365 | self, 366 | _name: &'static str, 367 | _variants: &'static [&'static str], 368 | visitor: V, 369 | ) -> Result 370 | where 371 | V: Visitor<'de>, 372 | { 373 | match self { 374 | Value::XStr(s) | Value::String(s) => visitor.visit_enum(s.into_deserializer()), 375 | _ => self.deserialize_any(visitor), 376 | } 377 | } 378 | 379 | fn deserialize_newtype_struct( 380 | self, 381 | _name: &'static str, 382 | visitor: V, 383 | ) -> Result 384 | where 385 | V: Visitor<'de>, 386 | { 387 | match self { 388 | Value::XStr(s) | Value::String(s) => { 389 | visitor.visit_newtype_struct(s.into_deserializer()) 390 | } 391 | _ => self.deserialize_any(visitor), 392 | } 393 | } 394 | 395 | serde::forward_to_deserialize_any! { 396 | str string bytes byte_buf unit seq tuple 397 | tuple_struct map unit_struct struct identifier ignored_any 398 | } 399 | } 400 | 401 | pub use serde::de::{DeserializeSeed, IntoDeserializer}; 402 | -------------------------------------------------------------------------------- /src/json.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use actson::{ 4 | JsonEvent, JsonParser, 5 | feeder::{JsonFeeder, SliceJsonFeeder}, 6 | }; 7 | use log::debug; 8 | 9 | use crate::{Error, Number, Value}; 10 | 11 | #[derive(Debug)] 12 | pub enum JsonError { 13 | SyntaxError(String), 14 | NoMoreInput, 15 | Other(String), 16 | } 17 | 18 | impl From for Error { 19 | fn from(err: JsonError) -> Self { 20 | match err { 21 | JsonError::SyntaxError(e) => Error::DecodeError(format!("Syntax error: {}", e)), 22 | JsonError::NoMoreInput => Error::DecodeError("Incomplete JSON input".to_string()), 23 | JsonError::Other(msg) => Error::DecodeError(msg), 24 | } 25 | } 26 | } 27 | 28 | impl From<&serde_json::Value> for Value { 29 | fn from(v: &serde_json::Value) -> Self { 30 | match v { 31 | serde_json::Value::Null => Value::Null, 32 | serde_json::Value::Bool(v) => Value::Bool(*v), 33 | serde_json::Value::Number(n) => { 34 | let n = n.as_f64().unwrap(); 35 | if n.is_nan() { 36 | Value::Null 37 | } else { 38 | Value::Number(n.into()) 39 | } 40 | } 41 | serde_json::Value::String(v) => Value::String(v.clone()), 42 | serde_json::Value::Array(v) => { 43 | Value::Array(v.iter().map(Value::from).collect::>()) 44 | } 45 | serde_json::Value::Object(v) => Value::Object( 46 | v.iter() 47 | .map(|(k, v)| (k.clone(), Value::from(v))) 48 | .collect::>(), 49 | ), 50 | } 51 | } 52 | } 53 | 54 | fn unescape_json_string(s: &str) -> Result { 55 | let mut result = String::with_capacity(s.len()); 56 | let mut chars = s.chars().peekable(); 57 | 58 | while let Some(c) = chars.next() { 59 | if c == '\\' { 60 | match chars.next() { 61 | Some('"') => result.push('"'), 62 | Some('\\') => result.push('\\'), 63 | Some('/') => result.push('/'), 64 | Some('b') => result.push('\u{0008}'), 65 | Some('f') => result.push('\u{000C}'), 66 | Some('n') => result.push('\n'), 67 | Some('r') => result.push('\r'), 68 | Some('t') => result.push('\t'), 69 | Some('u') => { 70 | let mut code = String::with_capacity(4); 71 | for _ in 0..4 { 72 | if let Some(hex) = chars.next() { 73 | code.push(hex); 74 | } else { 75 | return Err(JsonError::SyntaxError("Missing hex digits".to_string())); 76 | } 77 | } 78 | if let Ok(code) = u32::from_str_radix(&code, 16) { 79 | if let Some(c) = char::from_u32(code) { 80 | result.push(c); 81 | } else { 82 | return Err(JsonError::SyntaxError("Invalid Unicode".to_string())); 83 | } 84 | } else { 85 | return Err(JsonError::SyntaxError("Invalid hex digits".to_string())); 86 | } 87 | } 88 | Some(c) => { 89 | result.push('\\'); 90 | result.push(c); 91 | } 92 | None => { 93 | result.push('\\'); 94 | break; 95 | } 96 | } 97 | } else { 98 | result.push(c); 99 | } 100 | } 101 | Ok(result) 102 | } 103 | 104 | fn json_event_to_value( 105 | event: &JsonEvent, 106 | parser: &JsonParser, 107 | ) -> Result { 108 | match event { 109 | JsonEvent::ValueString => Ok(Value::String(unescape_json_string( 110 | parser.current_str().unwrap(), 111 | )?)), 112 | JsonEvent::ValueInt => Ok(Value::Number(Number::from( 113 | parser.current_str().unwrap().parse::().unwrap(), 114 | ))), 115 | JsonEvent::ValueFloat => Ok(Value::Number(Number::from( 116 | parser.current_str().unwrap().parse::().unwrap(), 117 | ))), 118 | JsonEvent::ValueTrue => Ok(Value::Bool(true)), 119 | JsonEvent::ValueFalse => Ok(Value::Bool(false)), 120 | JsonEvent::ValueNull => Ok(Value::Null), 121 | other => Err(JsonError::SyntaxError(format!( 122 | "Unexpected JSON event in parsing value: {:?}", 123 | other 124 | ))), 125 | } 126 | } 127 | 128 | pub fn parse_json(feeder: SliceJsonFeeder) -> Result { 129 | let mut parser = JsonParser::new(feeder); 130 | 131 | let mut stack = vec![]; 132 | let mut result = None; 133 | let mut current_key = None; 134 | 135 | while let Some(event) = parser 136 | .next_event() 137 | .map_err(|e| JsonError::SyntaxError(format!("parse error:{}", e)))? 138 | { 139 | debug!("JSON event: {:?}", event); 140 | match event { 141 | JsonEvent::NeedMoreInput => {} 142 | 143 | JsonEvent::StartObject | JsonEvent::StartArray => { 144 | let v = if event == JsonEvent::StartObject { 145 | Value::Object(HashMap::new()) 146 | } else { 147 | Value::Array(vec![]) 148 | }; 149 | stack.push((current_key.take(), v)); 150 | } 151 | 152 | JsonEvent::EndObject | JsonEvent::EndArray => { 153 | let v = stack.pop().unwrap(); 154 | if let Some((_, top)) = stack.last_mut() { 155 | match top { 156 | Value::Object(o) => { 157 | if let Some(key) = v.0 { 158 | o.insert(key, v.1); 159 | } 160 | } 161 | Value::Array(a) => { 162 | a.push(v.1); 163 | } 164 | _ => { 165 | return Err(JsonError::SyntaxError( 166 | "Invalid JSON array end".to_string(), 167 | )); 168 | } 169 | } 170 | } else { 171 | result = Some(v.1); 172 | } 173 | } 174 | 175 | JsonEvent::FieldName => { 176 | let str_result = parser 177 | .current_str() 178 | .map_err(|e| JsonError::SyntaxError(format!("parse error:{}", e)))?; 179 | current_key = Some(str_result.to_string()); 180 | } 181 | 182 | JsonEvent::ValueString 183 | | JsonEvent::ValueInt 184 | | JsonEvent::ValueFloat 185 | | JsonEvent::ValueTrue 186 | | JsonEvent::ValueFalse 187 | | JsonEvent::ValueNull => { 188 | let v = json_event_to_value(&event, &parser)?; 189 | if let Some((_, top)) = stack.last_mut() { 190 | match top { 191 | Value::Array(a) => { 192 | a.push(v); 193 | } 194 | Value::Object(o) => { 195 | if let Some(key) = current_key.take() { 196 | o.insert(key, v); 197 | } else { 198 | return Err(JsonError::SyntaxError( 199 | "Invalid JSON object key".to_string(), 200 | )); 201 | } 202 | } 203 | other => { 204 | return Err(JsonError::SyntaxError(format!( 205 | "Unexpected JSON value in {}", 206 | other.type_name() 207 | ))); 208 | } 209 | } 210 | } else if result.is_none() { 211 | result = Some(v); 212 | } else { 213 | return Err(JsonError::SyntaxError("Unexpected JSON value".to_string())); 214 | } 215 | } 216 | } 217 | } 218 | 219 | result.ok_or(JsonError::NoMoreInput) 220 | } 221 | 222 | #[cfg(test)] 223 | mod tests { 224 | use actson::feeder::SliceJsonFeeder; 225 | 226 | use crate::{N, Number, Value, parse_json}; 227 | 228 | #[test] 229 | fn test_parse_json_numbers() { 230 | // Test positive integers 231 | let json = r#"{"pos": 42, "zero": 0, "big": 9007199254740991}"#; 232 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 233 | if let Value::Object(map) = result { 234 | assert!(matches!(map["pos"], Value::Number(Number(N::PosInt(42))))); 235 | assert!(matches!(map["zero"], Value::Number(Number(N::PosInt(0))))); 236 | assert!(matches!( 237 | map["big"], 238 | Value::Number(Number(N::PosInt(9007199254740991))) 239 | )); 240 | } else { 241 | panic!("Expected object"); 242 | } 243 | 244 | // Test negative integers 245 | let json = r#"{"neg": -42, "min": -9007199254740991}"#; 246 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 247 | if let Value::Object(map) = result { 248 | assert!(matches!(map["neg"], Value::Number(Number(N::NegInt(-42))))); 249 | assert!(matches!( 250 | map["min"], 251 | Value::Number(Number(N::NegInt(-9007199254740991))) 252 | )); 253 | } else { 254 | panic!("Expected object"); 255 | } 256 | 257 | // Test floating point numbers 258 | let json = r#"{ 259 | "float": 42.5, 260 | "neg_float": -42.5, 261 | "zero_float": 0.0, 262 | "exp": 1.23e5, 263 | "neg_exp": -1.23e-5 264 | }"#; 265 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 266 | if let Value::Object(map) = result { 267 | assert!( 268 | matches!(map["float"], Value::Number(Number(N::Float(v))) if (v - 42.5).abs() < f64::EPSILON) 269 | ); 270 | assert!( 271 | matches!(map["neg_float"], Value::Number(Number(N::Float(v))) if (v - (-42.5)).abs() < f64::EPSILON) 272 | ); 273 | assert!( 274 | matches!(map["zero_float"], Value::Number(Number(N::Float(v))) if v.abs() < f64::EPSILON) 275 | ); 276 | assert!( 277 | matches!(map["exp"], Value::Number(Number(N::Float(v))) if (v - 123000.0).abs() < f64::EPSILON) 278 | ); 279 | assert!( 280 | matches!(map["neg_exp"], Value::Number(Number(N::Float(v))) if (v - (-0.0000123)).abs() < f64::EPSILON) 281 | ); 282 | } else { 283 | panic!("Expected object"); 284 | } 285 | 286 | // Test array of numbers 287 | let json = r#"[42, -42, 42.5, 0, -0.0]"#; 288 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 289 | if let Value::Array(arr) = result { 290 | assert!(matches!(arr[0], Value::Number(Number(N::PosInt(42))))); 291 | assert!(matches!(arr[1], Value::Number(Number(N::NegInt(-42))))); 292 | assert!( 293 | matches!(arr[2], Value::Number(Number(N::Float(v))) if (v - 42.5).abs() < f64::EPSILON) 294 | ); 295 | assert!(matches!(arr[3], Value::Number(Number(N::PosInt(0))))); 296 | assert!(matches!(arr[4], Value::Number(Number(N::Float(v))) if v.abs() < f64::EPSILON)); 297 | } else { 298 | panic!("Expected array"); 299 | } 300 | } 301 | 302 | #[test] 303 | fn test_parse_json_mixed_types() { 304 | let json = r#"{ 305 | "number": 42, 306 | "string": "hello", 307 | "bool": true, 308 | "null": null, 309 | "array": [1, "two", false], 310 | "nested": {"a": 1, "b": 2} 311 | }"#; 312 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 313 | if let Value::Object(map) = result { 314 | assert!(matches!( 315 | map["number"], 316 | Value::Number(Number(N::PosInt(42))) 317 | )); 318 | assert!(matches!(map["string"], Value::String(ref s) if s == "hello")); 319 | assert!(matches!(map["bool"], Value::Bool(b) if b)); 320 | assert!(matches!(map["null"], Value::Null)); 321 | 322 | if let Value::Array(arr) = &map["array"] { 323 | assert!(matches!(arr[0], Value::Number(Number(N::PosInt(1))))); 324 | assert!(matches!(arr[1], Value::String(ref s) if s == "two")); 325 | assert!(matches!(arr[2], Value::Bool(b) if !b)); 326 | } else { 327 | panic!("Expected array"); 328 | } 329 | 330 | if let Value::Object(nested) = &map["nested"] { 331 | assert!(matches!(nested["a"], Value::Number(Number(N::PosInt(1))))); 332 | assert!(matches!(nested["b"], Value::Number(Number(N::PosInt(2))))); 333 | } else { 334 | panic!("Expected nested object"); 335 | } 336 | } else { 337 | panic!("Expected object"); 338 | } 339 | } 340 | 341 | #[test] 342 | fn test_parse_json_escape_chars() { 343 | let json = r#"{ 344 | "escaped_quotes": "hello \"world\"", 345 | "escaped_slash": "hello\/world", 346 | "escaped_backslash": "back\\slash", 347 | "escaped_controls": "\b\f\n\r\t", 348 | "escaped_unicode": "\u0041\u0042C", 349 | "mixed_escapes": "Hello\n\"World\"\\\u0021" 350 | }"#; 351 | let result = parse_json(SliceJsonFeeder::new(json.as_bytes())).unwrap(); 352 | 353 | if let Value::Object(map) = result { 354 | assert!(matches!( 355 | map["escaped_quotes"], 356 | Value::String(ref s) if s == "hello \"world\"" 357 | )); 358 | assert!(matches!( 359 | map["escaped_slash"], 360 | Value::String(ref s) if s == "hello/world" 361 | )); 362 | assert!(matches!( 363 | map["escaped_backslash"], 364 | Value::String(ref s) if s == "back\\slash" 365 | )); 366 | assert!(matches!( 367 | map["escaped_controls"], 368 | Value::String(ref s) if s == "\u{0008}\u{000C}\n\r\t" 369 | )); 370 | assert!(matches!( 371 | map["escaped_unicode"], 372 | Value::String(ref s) if s == "ABC" 373 | )); 374 | assert!(matches!( 375 | map["mixed_escapes"], 376 | Value::String(ref s) if s == "Hello\n\"World\"\\!" 377 | )); 378 | } else { 379 | panic!("Expected object"); 380 | } 381 | 382 | // Test invalid escape sequences 383 | let invalid_json = r#"{"invalid": "\z"}"#; 384 | assert!(parse_json(SliceJsonFeeder::new(invalid_json.as_bytes())).is_err()); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/query_parser.rs: -------------------------------------------------------------------------------- 1 | // Port from: https://github.com/rack/rack/blob/main/lib/rack/query_parser.rb 2 | 3 | use form_urlencoded; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | use std::fmt; 7 | 8 | use crate::Value; 9 | 10 | const DEFAULT_PARAM_DEPTH_LIMIT: usize = 100; 11 | 12 | #[derive(Debug)] 13 | pub enum QueryParserError { 14 | ParameterTypeError(String), 15 | InvalidParameterError(String), 16 | ParamsTooDeepError(String), 17 | } 18 | 19 | impl fmt::Display for QueryParserError { 20 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 21 | match self { 22 | QueryParserError::ParameterTypeError(msg) => write!(f, "Parameter type error: {}", msg), 23 | QueryParserError::InvalidParameterError(msg) => write!(f, "Invalid parameter: {}", msg), 24 | QueryParserError::ParamsTooDeepError(msg) => write!(f, "Parameters too deep: {}", msg), 25 | } 26 | } 27 | } 28 | 29 | impl Error for QueryParserError {} 30 | 31 | pub struct QueryParser { 32 | param_depth_limit: usize, 33 | } 34 | 35 | impl QueryParser { 36 | pub fn new(param_depth_limit: Option) -> Self { 37 | Self { 38 | param_depth_limit: param_depth_limit.unwrap_or(DEFAULT_PARAM_DEPTH_LIMIT), 39 | } 40 | } 41 | 42 | pub fn parse_nested_query<'a>( 43 | &self, 44 | qs: impl Into>, 45 | ) -> Result, QueryParserError> { 46 | let mut params = HashMap::new(); 47 | self.parse_nested_query_into(&mut params, qs)?; 48 | Ok(params) 49 | } 50 | 51 | pub fn parse_nested_query_into<'a>( 52 | &self, 53 | params: &mut HashMap, 54 | qs: impl Into>, 55 | ) -> Result<(), QueryParserError> { 56 | let qs = qs.into().unwrap_or(""); 57 | 58 | if qs.is_empty() { 59 | return Ok(()); 60 | } 61 | 62 | for pair in qs.split('&') { 63 | if pair.is_empty() { 64 | continue; 65 | } 66 | 67 | let (key, value) = match pair.split_once('=') { 68 | Some((k, v)) => { 69 | let k = form_urlencoded::parse(k.as_bytes()) 70 | .next() 71 | .map(|(k, _)| k.into_owned()) 72 | .unwrap_or_default(); 73 | let v = form_urlencoded::parse(v.as_bytes()) 74 | .next() 75 | .map(|(v, _)| v.into_owned()) 76 | .unwrap_or_default(); 77 | (k, Some(v)) 78 | } 79 | None => { 80 | let k = form_urlencoded::parse(pair.as_bytes()) 81 | .next() 82 | .map(|(k, _)| k.into_owned()) 83 | .unwrap_or_default(); 84 | (k, None) 85 | } 86 | }; 87 | 88 | let value = Value::xstr_opt(value); 89 | self._normalize_params(params, &key, value, 0)?; 90 | } 91 | 92 | Ok(()) 93 | } 94 | 95 | pub fn parse_nested_value<'a>( 96 | &self, 97 | params: &mut HashMap, 98 | key: impl Into>, 99 | value: Value, 100 | ) -> Result<(), QueryParserError> { 101 | let key = key.into().unwrap_or(""); 102 | 103 | if key.is_empty() { 104 | return Ok(()); 105 | } 106 | 107 | self._normalize_params(params, key, value, 0)?; 108 | Ok(()) 109 | } 110 | 111 | fn _normalize_params( 112 | &self, 113 | params: &mut HashMap, 114 | name: &str, 115 | v: Value, 116 | depth: usize, 117 | ) -> Result { 118 | if depth >= self.param_depth_limit { 119 | return Err(QueryParserError::ParamsTooDeepError( 120 | "Parameters nested too deep".to_string(), 121 | )); 122 | } 123 | 124 | let (k, after) = if name.is_empty() { 125 | ("", "") 126 | } else if depth == 0 { 127 | if let Some(start) = name[1..].find('[') { 128 | let start = start + 1; 129 | (&name[..start], &name[start..]) 130 | } else { 131 | (name, "") 132 | } 133 | } else if let Some(stripped) = name.strip_prefix("[]") { 134 | ("[]", stripped) 135 | } else if let Some(stripped) = name.strip_prefix("[") { 136 | if let Some(start) = stripped.find(']') { 137 | (&stripped[..start], &stripped[start + 1..]) 138 | } else { 139 | (name, "") 140 | } 141 | } else { 142 | (name, "") 143 | }; 144 | 145 | if k.is_empty() { 146 | return Ok(Value::Null); 147 | } 148 | 149 | if after.is_empty() { 150 | if k == "[]" && depth != 0 { 151 | return Ok(Value::Array(vec![v])); 152 | } 153 | params.insert(k.to_string(), v); 154 | } else if after == "[" { 155 | params.insert(name.to_string(), v); 156 | } else if after == "[]" { 157 | let entry = params 158 | .entry(k.to_string()) 159 | .or_insert_with(|| Value::Array(Vec::new())); 160 | 161 | if let Value::Array(vec) = entry { 162 | vec.push(v); 163 | } else { 164 | return Err(QueryParserError::ParameterTypeError(format!( 165 | "expected Array (got {}) for param `{}`", 166 | entry.type_name(), 167 | k 168 | ))); 169 | } 170 | } else if let Some(after) = after.strip_prefix("[]") { 171 | // Recognize x[][y] (hash inside array) parameters 172 | let child_key = if !after.starts_with('[') 173 | || !after.ends_with(']') 174 | || after[1..after.len() - 1].contains('[') 175 | || after[1..after.len() - 1].contains(']') 176 | || after[1..after.len() - 1].is_empty() 177 | { 178 | after 179 | } else { 180 | &after[1..after.len() - 1] 181 | }; 182 | 183 | let entry = params 184 | .entry(k.to_string()) 185 | .or_insert_with(|| Value::Array(Vec::new())); 186 | if let Value::Array(vec) = entry { 187 | let mut new_params = HashMap::new(); 188 | if let Some(Value::Object(hash)) = vec.last_mut() { 189 | if !params_hash_has_key(hash, child_key) { 190 | let _ = self._normalize_params(&mut *hash, child_key, v.clone(), depth + 1); 191 | } else { 192 | let normalized = self._normalize_params( 193 | &mut new_params, 194 | child_key, 195 | v.clone(), 196 | depth + 1, 197 | )?; 198 | vec.push(normalized); 199 | } 200 | } else { 201 | let normalized = 202 | self._normalize_params(&mut new_params, child_key, v.clone(), depth + 1)?; 203 | vec.push(normalized); 204 | } 205 | } else { 206 | return Err(QueryParserError::ParameterTypeError(format!( 207 | "expected Array (got {}) for param `{}`", 208 | entry.type_name(), 209 | k 210 | ))); 211 | } 212 | } else { 213 | let entry = params 214 | .entry(k.to_string()) 215 | .or_insert_with(|| Value::Object(HashMap::new())); 216 | 217 | if let Value::Object(hash) = entry { 218 | self._normalize_params(hash, after, v, depth + 1)?; 219 | } else { 220 | return Err(QueryParserError::ParameterTypeError(format!( 221 | "expected Object (got {}) for param `{}`", 222 | entry.type_name(), 223 | k 224 | ))); 225 | } 226 | } 227 | 228 | Ok(Value::Object(params.to_owned())) 229 | } 230 | } 231 | 232 | fn params_hash_has_key(hash: &HashMap, key: &str) -> bool { 233 | if key.contains("[]") { 234 | return false; 235 | } 236 | let parts: Vec<&str> = key 237 | .split(['[', ']']) 238 | .filter(|&part| !part.is_empty()) 239 | .collect(); 240 | 241 | let mut current = hash; 242 | for part in parts { 243 | if let Some(next) = current.get(part) { 244 | if let Value::Object(map) = next { 245 | current = map; 246 | } else { 247 | return true; 248 | } 249 | } else { 250 | return false; 251 | } 252 | } 253 | true 254 | } 255 | 256 | #[cfg(test)] 257 | mod tests { 258 | // Port from: https://github.com/rack/rack/blob/main/test/spec_utils.rb 259 | 260 | use crate::query_parser::{DEFAULT_PARAM_DEPTH_LIMIT, QueryParser, Value}; 261 | use maplit::hashmap; 262 | use pretty_assertions::assert_eq; 263 | use std::collections::HashMap; 264 | 265 | trait ParseTest { 266 | fn should_be(&self, expected: &str); 267 | } 268 | 269 | impl<'a> ParseTest for &'a str { 270 | fn should_be(&self, expected: &str) { 271 | let parser = QueryParser::new(None); 272 | assert_eq!( 273 | Value::Object(parser.parse_nested_query(*self).unwrap()), 274 | convert(expected) 275 | ); 276 | } 277 | } 278 | 279 | fn convert(json: &str) -> Value { 280 | let json: serde_json::Value = serde_json::from_str(json).unwrap(); 281 | Value::from(&json) 282 | } 283 | 284 | fn setup() { 285 | let _ = env_logger::builder().is_test(true).try_init(); 286 | } 287 | 288 | #[test] 289 | fn parse_nil_as_an_empty_query_string() { 290 | let parser = QueryParser::new(None); 291 | assert_eq!(parser.parse_nested_query(None).unwrap(), HashMap::new()); 292 | } 293 | 294 | #[test] 295 | fn raise_an_exception_if_the_params_are_too_deep() { 296 | let parser = QueryParser::new(Some(DEFAULT_PARAM_DEPTH_LIMIT)); 297 | let deep_string = "[a]".repeat(DEFAULT_PARAM_DEPTH_LIMIT); 298 | let query_string = format!("foo{}=bar", deep_string); 299 | let result = parser.parse_nested_query(&*query_string); 300 | assert!(result.is_err()); 301 | } 302 | 303 | #[test] 304 | fn test_parse_nested_query_strings_correctly() { 305 | setup(); 306 | 307 | "foo".should_be(r#"{"foo": null}"#); 308 | "foo=".should_be(r#"{"foo": ""}"#); 309 | "foo=bar".should_be(r#"{"foo": "bar"}"#); 310 | "foo=\"bar\"".should_be(r#"{"foo": "\"bar\""}"#); 311 | 312 | "foo=bar&foo=quux".should_be(r#"{"foo": "quux"}"#); 313 | "foo&foo=".should_be(r#"{"foo": ""}"#); 314 | "foo=1&bar=2".should_be(r#"{"foo": "1", "bar": "2"}"#); 315 | "&foo=1&&bar=2".should_be(r#"{"foo": "1", "bar": "2"}"#); 316 | "foo&bar=".should_be(r#"{"foo": null, "bar": ""}"#); 317 | "foo=bar&baz=".should_be(r#"{"foo": "bar", "baz": ""}"#); 318 | "&foo=1&&bar=2".should_be(r#"{"foo": "1", "bar": "2"}"#); 319 | "foo&bar=".should_be(r#"{"foo": null, "bar": ""}"#); 320 | "foo=bar&baz=".should_be(r#"{"foo": "bar", "baz": ""}"#); 321 | "my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F" 322 | .should_be(r#"{"my weird field": "q1!2\"'w$5&7/z8)?"}"#); 323 | 324 | "a=b&pid%3D1234=1023".should_be(r#"{"pid=1234": "1023", "a": "b"}"#); 325 | 326 | "foo[]".should_be(r#"{"foo": [null]}"#); 327 | "foo[]=".should_be(r#"{"foo": [""]}"#); 328 | "foo[]=bar".should_be(r#"{"foo": ["bar"]}"#); 329 | "foo[]=bar&foo".should_be(r#"{"foo": null}"#); 330 | "foo[]=bar&foo[".should_be(r#"{"foo": ["bar"], "foo[": null}"#); 331 | "foo[]=bar&foo[=baz".should_be(r#"{"foo": ["bar"], "foo[": "baz"}"#); 332 | "foo[]=bar&foo[]".should_be(r#"{"foo": ["bar", null]}"#); 333 | "foo[]=bar&foo[]=".should_be(r#"{"foo": ["bar", ""]}"#); 334 | 335 | "foo[]=1&foo[]=2".should_be(r#"{"foo": ["1", "2"]}"#); 336 | "foo=bar&baz[]=1&baz[]=2&baz[]=3".should_be(r#"{"foo": "bar", "baz": ["1", "2", "3"]}"#); 337 | "foo[]=bar&baz[]=1&baz[]=2&baz[]=3" 338 | .should_be(r#"{"foo": ["bar"], "baz": ["1", "2", "3"]}"#); 339 | 340 | "x[y][z]".should_be(r#"{"x": { "y": { "z": null } }}"#); 341 | "x[y][z]=1".should_be(r#"{"x": { "y": { "z": "1"} }}"#); 342 | "x[y][z][]=1".should_be(r#"{"x": { "y": { "z": ["1"] } }}"#); 343 | "x[y][z]=1&x[y][z]=2".should_be(r#"{"x": { "y": { "z": "2"} }}"#); 344 | "x[y][z][]=1&x[y][z][]=2".should_be(r#"{"x": { "y": { "z": ["1", "2"] } }}"#); 345 | 346 | "x[y][][z]=1".should_be(r#"{"x": { "y": [{ "z": "1" }] }}"#); 347 | "x[y][][z][]=1".should_be(r#"{"x": { "y": [{ "z": ["1"] }] }}"#); 348 | "x[y][][z]=1&x[y][][w]=2".should_be(r#"{"x": { "y": [{ "z": "1", "w": "2" }] }}"#); 349 | 350 | "x[y][][v][w]=1".should_be(r#"{"x": { "y": [{ "v": { "w": "1" } }] }}"#); 351 | "x[y][][z]=1&x[y][][v][w]=2" 352 | .should_be(r#"{"x": { "y": [{ "z": "1", "v": { "w": "2" } }] }}"#); 353 | 354 | "x[y][][z]=1&x[y][][z]=2".should_be(r#"{"x": { "y": [{ "z": "1" }, { "z": "2" }] }}"#); 355 | "x[y][][z]=1&x[y][][w]=a&x[y][][z]=2&x[y][][w]=3" 356 | .should_be(r#"{"x": { "y": [{ "z": "1", "w": "a" }, { "z": "2", "w": "3" }] }}"#); 357 | 358 | "x[][y]=1&x[][z][w]=a&x[][y]=2&x[][z][w]=b".should_be( 359 | r#"{"x": [{ "y": "1", "z": { "w": "a" } }, { "y": "2", "z": { "w": "b" } }]}"#, 360 | ); 361 | "x[][z][w]=a&x[][y]=1&x[][z][w]=b&x[][y]=2".should_be( 362 | r#"{"x": [{ "y": "1", "z": { "w": "a" } }, { "y": "2", "z": { "w": "b" } }]}"#, 363 | ); 364 | 365 | "data[books][][data][page]=1&data[books][][data][page]=2".should_be( 366 | r#"{"data": { "books": [{ "data": { "page": "1" } }, { "data": { "page": "2" } }] }}"#, 367 | ) 368 | } 369 | 370 | #[test] 371 | fn test_parse_empty() { 372 | let parser = QueryParser::new(None); 373 | assert_eq!(parser.parse_nested_query("").unwrap(), HashMap::new()); 374 | assert_eq!(parser.parse_nested_query(None).unwrap(), HashMap::new()); 375 | } 376 | 377 | #[test] 378 | fn test_parse_empty_key_value() { 379 | let parser = QueryParser::new(None); 380 | 381 | // Test empty key with value 382 | assert_eq!(parser.parse_nested_query("=value").unwrap(), hashmap! {}); 383 | 384 | // Test key with empty value 385 | assert_eq!( 386 | parser.parse_nested_query("key=").unwrap(), 387 | hashmap! { 388 | "key".to_string() => Value::xstr("") 389 | } 390 | ); 391 | 392 | // Test empty key-value pair 393 | assert_eq!(parser.parse_nested_query("=").unwrap(), hashmap! {}); 394 | 395 | // Test key without value 396 | assert_eq!( 397 | parser.parse_nested_query("&key&").unwrap(), 398 | hashmap! { 399 | "key".to_string() => Value::Null 400 | } 401 | ); 402 | } 403 | 404 | #[test] 405 | fn test_parse_duplicate_keys() { 406 | let parser = QueryParser::new(None); 407 | 408 | // Test duplicate keys (last value wins) 409 | assert_eq!( 410 | parser.parse_nested_query("foo=bar&foo=quux").unwrap(), 411 | hashmap! { 412 | "foo".to_string() => Value::xstr("quux") 413 | } 414 | ); 415 | 416 | // Test key without value followed by key with value 417 | assert_eq!( 418 | parser.parse_nested_query("foo&foo=").unwrap(), 419 | hashmap! { 420 | "foo".to_string() => Value::xstr("") 421 | } 422 | ); 423 | 424 | // Test key with value followed by key without value 425 | assert_eq!( 426 | parser.parse_nested_query("foo=bar&foo").unwrap(), 427 | hashmap! { 428 | "foo".to_string() => Value::Null 429 | } 430 | ); 431 | } 432 | 433 | #[test] 434 | fn test_parse_array_edge_cases() { 435 | setup(); 436 | let parser = QueryParser::new(None); 437 | 438 | // Test array followed by plain key 439 | assert_eq!( 440 | parser.parse_nested_query("foo[]=bar&foo").unwrap(), 441 | hashmap! { 442 | "foo".to_string() => Value::Null 443 | } 444 | ); 445 | 446 | // Test array followed by incomplete array syntax 447 | assert_eq!( 448 | parser.parse_nested_query("foo[]=bar&foo[").unwrap(), 449 | hashmap! { 450 | "foo".to_string() => Value::Array(vec![Value::xstr("bar")]), 451 | "foo[".to_string() => Value::Null 452 | } 453 | ); 454 | 455 | // Test array followed by incomplete array with value 456 | assert_eq!( 457 | parser.parse_nested_query("foo[]=bar&foo[=baz").unwrap(), 458 | hashmap! { 459 | "foo".to_string() => Value::Array(vec![Value::xstr("bar")]), 460 | "foo[".to_string() => Value::xstr("baz") 461 | } 462 | ); 463 | } 464 | 465 | #[test] 466 | // can parse a query string with a key that has invalid UTF-8 encoded bytes 467 | fn test_parse_invalid_utf8() { 468 | let parser = QueryParser::new(None); 469 | let result = parser.parse_nested_query("foo%81E=1").unwrap_or_default(); 470 | assert_eq!(result.len(), 1); 471 | let key = result.keys().next().unwrap().as_bytes(); 472 | assert_eq!(key, b"foo\xEF\xBF\xBDE"); 473 | } 474 | 475 | #[test] 476 | fn only_moves_to_a_new_array_when_the_full_key_has_been_seen() { 477 | "x[][y][][z]=1&x[][y][][w]=2".should_be(r#"{"x": [{ "y": [{ "z": "1", "w": "2" }] }]}"#); 478 | "x[][id]=1&x[][y][a]=5&x[][y][b]=7&x[][z][id]=3&x[][z][w]=0&x[][id]=2&x[][y][a]=6&x[][y][b]=8&x[][z][id]=4&x[][z][w]=0" 479 | .should_be( 480 | r#" 481 | { 482 | "x": [ 483 | { "id": "1", "y": { "a": "5", "b": "7" }, "z": { "id": "3", "w": "0" } }, 484 | { "id": "2", "y": { "a": "6", "b": "8" }, "z": { "id": "4", "w": "0" } } 485 | ] 486 | }"#, 487 | ); 488 | } 489 | 490 | #[test] 491 | fn handles_unexpected_use_of_brackets_in_parameter_keys_as_normal_characters() { 492 | "[]=1&[a]=2&b[=3&c]=4".should_be(r#"{"[]": "1", "[a]": "2", "b[": "3", "c]": "4"}"#); 493 | "d[[]=5&e][]=6&f[[]]=7" 494 | .should_be(r#"{"d": {"[": "5"}, "e]": ["6"], "f": { "[": { "]": "7" } }}"#); 495 | "g[h]i=8&j[k]l[m]=9" 496 | .should_be(r#"{"g": { "h": { "i": "8" } }, "j": { "k": { "l[m]": "9" } }}"#); 497 | "l[[[[[[[[]]]]]]]=10".should_be(r#"{"l": {"[[[[[[[": {"]]]]]]": "10"}}}"#); 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "actson" 7 | version = "2.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "42ceaa5a04ff2e693fcfc1d39d1a12d04b66846b66ff5eb00033942785ec4464" 10 | dependencies = [ 11 | "btoi", 12 | "num-traits", 13 | "thiserror", 14 | ] 15 | 16 | [[package]] 17 | name = "addr2line" 18 | version = "0.24.2" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 21 | dependencies = [ 22 | "gimli", 23 | ] 24 | 25 | [[package]] 26 | name = "adler2" 27 | version = "2.0.0" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 30 | 31 | [[package]] 32 | name = "aho-corasick" 33 | version = "1.1.3" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 36 | dependencies = [ 37 | "memchr", 38 | ] 39 | 40 | [[package]] 41 | name = "anstream" 42 | version = "0.6.18" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 45 | dependencies = [ 46 | "anstyle", 47 | "anstyle-parse", 48 | "anstyle-query", 49 | "anstyle-wincon", 50 | "colorchoice", 51 | "is_terminal_polyfill", 52 | "utf8parse", 53 | ] 54 | 55 | [[package]] 56 | name = "anstyle" 57 | version = "1.0.10" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 60 | 61 | [[package]] 62 | name = "anstyle-parse" 63 | version = "0.2.6" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 66 | dependencies = [ 67 | "utf8parse", 68 | ] 69 | 70 | [[package]] 71 | name = "anstyle-query" 72 | version = "1.1.2" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 75 | dependencies = [ 76 | "windows-sys 0.59.0", 77 | ] 78 | 79 | [[package]] 80 | name = "anstyle-wincon" 81 | version = "3.0.7" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 84 | dependencies = [ 85 | "anstyle", 86 | "once_cell", 87 | "windows-sys 0.59.0", 88 | ] 89 | 90 | [[package]] 91 | name = "anyhow" 92 | version = "1.0.96" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" 95 | 96 | [[package]] 97 | name = "assert-json-diff" 98 | version = "2.0.2" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" 101 | dependencies = [ 102 | "serde", 103 | "serde_json", 104 | ] 105 | 106 | [[package]] 107 | name = "auto-future" 108 | version = "1.0.0" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" 111 | 112 | [[package]] 113 | name = "autocfg" 114 | version = "1.4.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 117 | 118 | [[package]] 119 | name = "axum" 120 | version = "0.8.3" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" 123 | dependencies = [ 124 | "axum-core", 125 | "axum-macros", 126 | "bytes", 127 | "form_urlencoded", 128 | "futures-util", 129 | "http", 130 | "http-body", 131 | "http-body-util", 132 | "hyper", 133 | "hyper-util", 134 | "itoa", 135 | "matchit", 136 | "memchr", 137 | "mime", 138 | "multer", 139 | "percent-encoding", 140 | "pin-project-lite", 141 | "rustversion", 142 | "serde", 143 | "serde_json", 144 | "serde_path_to_error", 145 | "serde_urlencoded", 146 | "sync_wrapper", 147 | "tokio", 148 | "tower", 149 | "tower-layer", 150 | "tower-service", 151 | "tracing", 152 | ] 153 | 154 | [[package]] 155 | name = "axum-core" 156 | version = "0.5.2" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" 159 | dependencies = [ 160 | "bytes", 161 | "futures-core", 162 | "http", 163 | "http-body", 164 | "http-body-util", 165 | "mime", 166 | "pin-project-lite", 167 | "rustversion", 168 | "sync_wrapper", 169 | "tower-layer", 170 | "tower-service", 171 | "tracing", 172 | ] 173 | 174 | [[package]] 175 | name = "axum-macros" 176 | version = "0.5.0" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" 179 | dependencies = [ 180 | "proc-macro2", 181 | "quote", 182 | "syn", 183 | ] 184 | 185 | [[package]] 186 | name = "axum-params" 187 | version = "0.4.1" 188 | dependencies = [ 189 | "actson", 190 | "axum", 191 | "axum-macros", 192 | "axum-test", 193 | "env_logger", 194 | "form_urlencoded", 195 | "futures-util", 196 | "log", 197 | "maplit", 198 | "multer", 199 | "pretty_assertions", 200 | "serde", 201 | "serde_json", 202 | "serde_urlencoded", 203 | "tempfile", 204 | "tokio", 205 | "url", 206 | ] 207 | 208 | [[package]] 209 | name = "axum-test" 210 | version = "17.3.0" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" 213 | dependencies = [ 214 | "anyhow", 215 | "assert-json-diff", 216 | "auto-future", 217 | "axum", 218 | "bytes", 219 | "bytesize", 220 | "cookie", 221 | "http", 222 | "http-body-util", 223 | "hyper", 224 | "hyper-util", 225 | "mime", 226 | "pretty_assertions", 227 | "reserve-port", 228 | "rust-multipart-rfc7578_2", 229 | "serde", 230 | "serde_json", 231 | "serde_urlencoded", 232 | "smallvec", 233 | "tokio", 234 | "tower", 235 | "url", 236 | ] 237 | 238 | [[package]] 239 | name = "backtrace" 240 | version = "0.3.74" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 243 | dependencies = [ 244 | "addr2line", 245 | "cfg-if", 246 | "libc", 247 | "miniz_oxide", 248 | "object", 249 | "rustc-demangle", 250 | "windows-targets", 251 | ] 252 | 253 | [[package]] 254 | name = "bitflags" 255 | version = "2.9.0" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 258 | 259 | [[package]] 260 | name = "btoi" 261 | version = "0.4.3" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" 264 | dependencies = [ 265 | "num-traits", 266 | ] 267 | 268 | [[package]] 269 | name = "byteorder" 270 | version = "1.5.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 273 | 274 | [[package]] 275 | name = "bytes" 276 | version = "1.10.0" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" 279 | 280 | [[package]] 281 | name = "bytesize" 282 | version = "2.0.1" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" 285 | 286 | [[package]] 287 | name = "cfg-if" 288 | version = "1.0.0" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 291 | 292 | [[package]] 293 | name = "colorchoice" 294 | version = "1.0.3" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 297 | 298 | [[package]] 299 | name = "cookie" 300 | version = "0.18.1" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 303 | dependencies = [ 304 | "time", 305 | "version_check", 306 | ] 307 | 308 | [[package]] 309 | name = "deranged" 310 | version = "0.3.11" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 313 | dependencies = [ 314 | "powerfmt", 315 | ] 316 | 317 | [[package]] 318 | name = "diff" 319 | version = "0.1.13" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 322 | 323 | [[package]] 324 | name = "displaydoc" 325 | version = "0.2.5" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 328 | dependencies = [ 329 | "proc-macro2", 330 | "quote", 331 | "syn", 332 | ] 333 | 334 | [[package]] 335 | name = "encoding_rs" 336 | version = "0.8.35" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 339 | dependencies = [ 340 | "cfg-if", 341 | ] 342 | 343 | [[package]] 344 | name = "env_filter" 345 | version = "0.1.3" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 348 | dependencies = [ 349 | "log", 350 | "regex", 351 | ] 352 | 353 | [[package]] 354 | name = "env_logger" 355 | version = "0.11.8" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" 358 | dependencies = [ 359 | "anstream", 360 | "anstyle", 361 | "env_filter", 362 | "jiff", 363 | "log", 364 | ] 365 | 366 | [[package]] 367 | name = "errno" 368 | version = "0.3.10" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 371 | dependencies = [ 372 | "libc", 373 | "windows-sys 0.59.0", 374 | ] 375 | 376 | [[package]] 377 | name = "fastrand" 378 | version = "2.3.0" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 381 | 382 | [[package]] 383 | name = "fnv" 384 | version = "1.0.7" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 387 | 388 | [[package]] 389 | name = "form_urlencoded" 390 | version = "1.2.1" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 393 | dependencies = [ 394 | "percent-encoding", 395 | ] 396 | 397 | [[package]] 398 | name = "futures-channel" 399 | version = "0.3.31" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 402 | dependencies = [ 403 | "futures-core", 404 | ] 405 | 406 | [[package]] 407 | name = "futures-core" 408 | version = "0.3.31" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 411 | 412 | [[package]] 413 | name = "futures-io" 414 | version = "0.3.31" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 417 | 418 | [[package]] 419 | name = "futures-macro" 420 | version = "0.3.31" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 423 | dependencies = [ 424 | "proc-macro2", 425 | "quote", 426 | "syn", 427 | ] 428 | 429 | [[package]] 430 | name = "futures-task" 431 | version = "0.3.31" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 434 | 435 | [[package]] 436 | name = "futures-util" 437 | version = "0.3.31" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 440 | dependencies = [ 441 | "futures-core", 442 | "futures-io", 443 | "futures-macro", 444 | "futures-task", 445 | "memchr", 446 | "pin-project-lite", 447 | "pin-utils", 448 | "slab", 449 | ] 450 | 451 | [[package]] 452 | name = "getrandom" 453 | version = "0.3.1" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 456 | dependencies = [ 457 | "cfg-if", 458 | "libc", 459 | "wasi 0.13.3+wasi-0.2.2", 460 | "windows-targets", 461 | ] 462 | 463 | [[package]] 464 | name = "gimli" 465 | version = "0.31.1" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 468 | 469 | [[package]] 470 | name = "http" 471 | version = "1.3.1" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 474 | dependencies = [ 475 | "bytes", 476 | "fnv", 477 | "itoa", 478 | ] 479 | 480 | [[package]] 481 | name = "http-body" 482 | version = "1.0.1" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 485 | dependencies = [ 486 | "bytes", 487 | "http", 488 | ] 489 | 490 | [[package]] 491 | name = "http-body-util" 492 | version = "0.1.2" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" 495 | dependencies = [ 496 | "bytes", 497 | "futures-util", 498 | "http", 499 | "http-body", 500 | "pin-project-lite", 501 | ] 502 | 503 | [[package]] 504 | name = "httparse" 505 | version = "1.10.0" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" 508 | 509 | [[package]] 510 | name = "httpdate" 511 | version = "1.0.3" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 514 | 515 | [[package]] 516 | name = "hyper" 517 | version = "1.6.0" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 520 | dependencies = [ 521 | "bytes", 522 | "futures-channel", 523 | "futures-util", 524 | "http", 525 | "http-body", 526 | "httparse", 527 | "httpdate", 528 | "itoa", 529 | "pin-project-lite", 530 | "smallvec", 531 | "tokio", 532 | "want", 533 | ] 534 | 535 | [[package]] 536 | name = "hyper-util" 537 | version = "0.1.10" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" 540 | dependencies = [ 541 | "bytes", 542 | "futures-channel", 543 | "futures-util", 544 | "http", 545 | "http-body", 546 | "hyper", 547 | "pin-project-lite", 548 | "socket2", 549 | "tokio", 550 | "tower-service", 551 | "tracing", 552 | ] 553 | 554 | [[package]] 555 | name = "icu_collections" 556 | version = "1.5.0" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 559 | dependencies = [ 560 | "displaydoc", 561 | "yoke", 562 | "zerofrom", 563 | "zerovec", 564 | ] 565 | 566 | [[package]] 567 | name = "icu_locid" 568 | version = "1.5.0" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 571 | dependencies = [ 572 | "displaydoc", 573 | "litemap", 574 | "tinystr", 575 | "writeable", 576 | "zerovec", 577 | ] 578 | 579 | [[package]] 580 | name = "icu_locid_transform" 581 | version = "1.5.0" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 584 | dependencies = [ 585 | "displaydoc", 586 | "icu_locid", 587 | "icu_locid_transform_data", 588 | "icu_provider", 589 | "tinystr", 590 | "zerovec", 591 | ] 592 | 593 | [[package]] 594 | name = "icu_locid_transform_data" 595 | version = "1.5.0" 596 | source = "registry+https://github.com/rust-lang/crates.io-index" 597 | checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" 598 | 599 | [[package]] 600 | name = "icu_normalizer" 601 | version = "1.5.0" 602 | source = "registry+https://github.com/rust-lang/crates.io-index" 603 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 604 | dependencies = [ 605 | "displaydoc", 606 | "icu_collections", 607 | "icu_normalizer_data", 608 | "icu_properties", 609 | "icu_provider", 610 | "smallvec", 611 | "utf16_iter", 612 | "utf8_iter", 613 | "write16", 614 | "zerovec", 615 | ] 616 | 617 | [[package]] 618 | name = "icu_normalizer_data" 619 | version = "1.5.0" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" 622 | 623 | [[package]] 624 | name = "icu_properties" 625 | version = "1.5.1" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 628 | dependencies = [ 629 | "displaydoc", 630 | "icu_collections", 631 | "icu_locid_transform", 632 | "icu_properties_data", 633 | "icu_provider", 634 | "tinystr", 635 | "zerovec", 636 | ] 637 | 638 | [[package]] 639 | name = "icu_properties_data" 640 | version = "1.5.0" 641 | source = "registry+https://github.com/rust-lang/crates.io-index" 642 | checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" 643 | 644 | [[package]] 645 | name = "icu_provider" 646 | version = "1.5.0" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 649 | dependencies = [ 650 | "displaydoc", 651 | "icu_locid", 652 | "icu_provider_macros", 653 | "stable_deref_trait", 654 | "tinystr", 655 | "writeable", 656 | "yoke", 657 | "zerofrom", 658 | "zerovec", 659 | ] 660 | 661 | [[package]] 662 | name = "icu_provider_macros" 663 | version = "1.5.0" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 666 | dependencies = [ 667 | "proc-macro2", 668 | "quote", 669 | "syn", 670 | ] 671 | 672 | [[package]] 673 | name = "idna" 674 | version = "1.0.3" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 677 | dependencies = [ 678 | "idna_adapter", 679 | "smallvec", 680 | "utf8_iter", 681 | ] 682 | 683 | [[package]] 684 | name = "idna_adapter" 685 | version = "1.2.0" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 688 | dependencies = [ 689 | "icu_normalizer", 690 | "icu_properties", 691 | ] 692 | 693 | [[package]] 694 | name = "is_terminal_polyfill" 695 | version = "1.70.1" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 698 | 699 | [[package]] 700 | name = "itoa" 701 | version = "1.0.14" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 704 | 705 | [[package]] 706 | name = "jiff" 707 | version = "0.2.4" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" 710 | dependencies = [ 711 | "jiff-static", 712 | "log", 713 | "portable-atomic", 714 | "portable-atomic-util", 715 | "serde", 716 | ] 717 | 718 | [[package]] 719 | name = "jiff-static" 720 | version = "0.2.4" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" 723 | dependencies = [ 724 | "proc-macro2", 725 | "quote", 726 | "syn", 727 | ] 728 | 729 | [[package]] 730 | name = "libc" 731 | version = "0.2.170" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" 734 | 735 | [[package]] 736 | name = "linux-raw-sys" 737 | version = "0.9.2" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" 740 | 741 | [[package]] 742 | name = "litemap" 743 | version = "0.7.5" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 746 | 747 | [[package]] 748 | name = "lock_api" 749 | version = "0.4.12" 750 | source = "registry+https://github.com/rust-lang/crates.io-index" 751 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 752 | dependencies = [ 753 | "autocfg", 754 | "scopeguard", 755 | ] 756 | 757 | [[package]] 758 | name = "log" 759 | version = "0.4.27" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 762 | 763 | [[package]] 764 | name = "maplit" 765 | version = "1.0.2" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 768 | 769 | [[package]] 770 | name = "matchit" 771 | version = "0.8.4" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 774 | 775 | [[package]] 776 | name = "memchr" 777 | version = "2.7.4" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 780 | 781 | [[package]] 782 | name = "mime" 783 | version = "0.3.17" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 786 | 787 | [[package]] 788 | name = "miniz_oxide" 789 | version = "0.8.5" 790 | source = "registry+https://github.com/rust-lang/crates.io-index" 791 | checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" 792 | dependencies = [ 793 | "adler2", 794 | ] 795 | 796 | [[package]] 797 | name = "mio" 798 | version = "1.0.3" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 801 | dependencies = [ 802 | "libc", 803 | "wasi 0.11.0+wasi-snapshot-preview1", 804 | "windows-sys 0.52.0", 805 | ] 806 | 807 | [[package]] 808 | name = "multer" 809 | version = "3.1.0" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" 812 | dependencies = [ 813 | "bytes", 814 | "encoding_rs", 815 | "futures-util", 816 | "http", 817 | "httparse", 818 | "memchr", 819 | "mime", 820 | "spin", 821 | "version_check", 822 | ] 823 | 824 | [[package]] 825 | name = "num-conv" 826 | version = "0.1.0" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 829 | 830 | [[package]] 831 | name = "num-traits" 832 | version = "0.2.19" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 835 | dependencies = [ 836 | "autocfg", 837 | ] 838 | 839 | [[package]] 840 | name = "object" 841 | version = "0.36.7" 842 | source = "registry+https://github.com/rust-lang/crates.io-index" 843 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 844 | dependencies = [ 845 | "memchr", 846 | ] 847 | 848 | [[package]] 849 | name = "once_cell" 850 | version = "1.20.3" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 853 | 854 | [[package]] 855 | name = "parking_lot" 856 | version = "0.12.3" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 859 | dependencies = [ 860 | "lock_api", 861 | "parking_lot_core", 862 | ] 863 | 864 | [[package]] 865 | name = "parking_lot_core" 866 | version = "0.9.10" 867 | source = "registry+https://github.com/rust-lang/crates.io-index" 868 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 869 | dependencies = [ 870 | "cfg-if", 871 | "libc", 872 | "redox_syscall", 873 | "smallvec", 874 | "windows-targets", 875 | ] 876 | 877 | [[package]] 878 | name = "percent-encoding" 879 | version = "2.3.1" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 882 | 883 | [[package]] 884 | name = "pin-project-lite" 885 | version = "0.2.16" 886 | source = "registry+https://github.com/rust-lang/crates.io-index" 887 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 888 | 889 | [[package]] 890 | name = "pin-utils" 891 | version = "0.1.0" 892 | source = "registry+https://github.com/rust-lang/crates.io-index" 893 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 894 | 895 | [[package]] 896 | name = "portable-atomic" 897 | version = "1.11.0" 898 | source = "registry+https://github.com/rust-lang/crates.io-index" 899 | checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 900 | 901 | [[package]] 902 | name = "portable-atomic-util" 903 | version = "0.2.4" 904 | source = "registry+https://github.com/rust-lang/crates.io-index" 905 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 906 | dependencies = [ 907 | "portable-atomic", 908 | ] 909 | 910 | [[package]] 911 | name = "powerfmt" 912 | version = "0.2.0" 913 | source = "registry+https://github.com/rust-lang/crates.io-index" 914 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 915 | 916 | [[package]] 917 | name = "ppv-lite86" 918 | version = "0.2.20" 919 | source = "registry+https://github.com/rust-lang/crates.io-index" 920 | checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 921 | dependencies = [ 922 | "zerocopy 0.7.35", 923 | ] 924 | 925 | [[package]] 926 | name = "pretty_assertions" 927 | version = "1.4.1" 928 | source = "registry+https://github.com/rust-lang/crates.io-index" 929 | checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" 930 | dependencies = [ 931 | "diff", 932 | "yansi", 933 | ] 934 | 935 | [[package]] 936 | name = "proc-macro2" 937 | version = "1.0.93" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 940 | dependencies = [ 941 | "unicode-ident", 942 | ] 943 | 944 | [[package]] 945 | name = "quote" 946 | version = "1.0.38" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 949 | dependencies = [ 950 | "proc-macro2", 951 | ] 952 | 953 | [[package]] 954 | name = "rand" 955 | version = "0.9.0" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" 958 | dependencies = [ 959 | "rand_chacha", 960 | "rand_core", 961 | "zerocopy 0.8.21", 962 | ] 963 | 964 | [[package]] 965 | name = "rand_chacha" 966 | version = "0.9.0" 967 | source = "registry+https://github.com/rust-lang/crates.io-index" 968 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 969 | dependencies = [ 970 | "ppv-lite86", 971 | "rand_core", 972 | ] 973 | 974 | [[package]] 975 | name = "rand_core" 976 | version = "0.9.3" 977 | source = "registry+https://github.com/rust-lang/crates.io-index" 978 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 979 | dependencies = [ 980 | "getrandom", 981 | ] 982 | 983 | [[package]] 984 | name = "redox_syscall" 985 | version = "0.5.9" 986 | source = "registry+https://github.com/rust-lang/crates.io-index" 987 | checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" 988 | dependencies = [ 989 | "bitflags", 990 | ] 991 | 992 | [[package]] 993 | name = "regex" 994 | version = "1.11.1" 995 | source = "registry+https://github.com/rust-lang/crates.io-index" 996 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 997 | dependencies = [ 998 | "aho-corasick", 999 | "memchr", 1000 | "regex-automata", 1001 | "regex-syntax", 1002 | ] 1003 | 1004 | [[package]] 1005 | name = "regex-automata" 1006 | version = "0.4.9" 1007 | source = "registry+https://github.com/rust-lang/crates.io-index" 1008 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1009 | dependencies = [ 1010 | "aho-corasick", 1011 | "memchr", 1012 | "regex-syntax", 1013 | ] 1014 | 1015 | [[package]] 1016 | name = "regex-syntax" 1017 | version = "0.8.5" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1020 | 1021 | [[package]] 1022 | name = "reserve-port" 1023 | version = "2.2.0" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "ba3747658ee2585ecf5607fa9887c92eff61b362ff5253dbf797dfeb73d33d78" 1026 | dependencies = [ 1027 | "thiserror", 1028 | ] 1029 | 1030 | [[package]] 1031 | name = "rust-multipart-rfc7578_2" 1032 | version = "0.8.0" 1033 | source = "registry+https://github.com/rust-lang/crates.io-index" 1034 | checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" 1035 | dependencies = [ 1036 | "bytes", 1037 | "futures-core", 1038 | "futures-util", 1039 | "http", 1040 | "mime", 1041 | "rand", 1042 | "thiserror", 1043 | ] 1044 | 1045 | [[package]] 1046 | name = "rustc-demangle" 1047 | version = "0.1.24" 1048 | source = "registry+https://github.com/rust-lang/crates.io-index" 1049 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 1050 | 1051 | [[package]] 1052 | name = "rustix" 1053 | version = "1.0.0" 1054 | source = "registry+https://github.com/rust-lang/crates.io-index" 1055 | checksum = "17f8dcd64f141950290e45c99f7710ede1b600297c91818bb30b3667c0f45dc0" 1056 | dependencies = [ 1057 | "bitflags", 1058 | "errno", 1059 | "libc", 1060 | "linux-raw-sys", 1061 | "windows-sys 0.59.0", 1062 | ] 1063 | 1064 | [[package]] 1065 | name = "rustversion" 1066 | version = "1.0.19" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 1069 | 1070 | [[package]] 1071 | name = "ryu" 1072 | version = "1.0.19" 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" 1074 | checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" 1075 | 1076 | [[package]] 1077 | name = "scopeguard" 1078 | version = "1.2.0" 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" 1080 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1081 | 1082 | [[package]] 1083 | name = "serde" 1084 | version = "1.0.219" 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" 1086 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1087 | dependencies = [ 1088 | "serde_derive", 1089 | ] 1090 | 1091 | [[package]] 1092 | name = "serde_derive" 1093 | version = "1.0.219" 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" 1095 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1096 | dependencies = [ 1097 | "proc-macro2", 1098 | "quote", 1099 | "syn", 1100 | ] 1101 | 1102 | [[package]] 1103 | name = "serde_json" 1104 | version = "1.0.140" 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" 1106 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1107 | dependencies = [ 1108 | "itoa", 1109 | "memchr", 1110 | "ryu", 1111 | "serde", 1112 | ] 1113 | 1114 | [[package]] 1115 | name = "serde_path_to_error" 1116 | version = "0.1.16" 1117 | source = "registry+https://github.com/rust-lang/crates.io-index" 1118 | checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" 1119 | dependencies = [ 1120 | "itoa", 1121 | "serde", 1122 | ] 1123 | 1124 | [[package]] 1125 | name = "serde_urlencoded" 1126 | version = "0.7.1" 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" 1128 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1129 | dependencies = [ 1130 | "form_urlencoded", 1131 | "itoa", 1132 | "ryu", 1133 | "serde", 1134 | ] 1135 | 1136 | [[package]] 1137 | name = "signal-hook-registry" 1138 | version = "1.4.2" 1139 | source = "registry+https://github.com/rust-lang/crates.io-index" 1140 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 1141 | dependencies = [ 1142 | "libc", 1143 | ] 1144 | 1145 | [[package]] 1146 | name = "slab" 1147 | version = "0.4.9" 1148 | source = "registry+https://github.com/rust-lang/crates.io-index" 1149 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 1150 | dependencies = [ 1151 | "autocfg", 1152 | ] 1153 | 1154 | [[package]] 1155 | name = "smallvec" 1156 | version = "1.14.0" 1157 | source = "registry+https://github.com/rust-lang/crates.io-index" 1158 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 1159 | 1160 | [[package]] 1161 | name = "socket2" 1162 | version = "0.5.8" 1163 | source = "registry+https://github.com/rust-lang/crates.io-index" 1164 | checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" 1165 | dependencies = [ 1166 | "libc", 1167 | "windows-sys 0.52.0", 1168 | ] 1169 | 1170 | [[package]] 1171 | name = "spin" 1172 | version = "0.9.8" 1173 | source = "registry+https://github.com/rust-lang/crates.io-index" 1174 | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 1175 | 1176 | [[package]] 1177 | name = "stable_deref_trait" 1178 | version = "1.2.0" 1179 | source = "registry+https://github.com/rust-lang/crates.io-index" 1180 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 1181 | 1182 | [[package]] 1183 | name = "syn" 1184 | version = "2.0.98" 1185 | source = "registry+https://github.com/rust-lang/crates.io-index" 1186 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 1187 | dependencies = [ 1188 | "proc-macro2", 1189 | "quote", 1190 | "unicode-ident", 1191 | ] 1192 | 1193 | [[package]] 1194 | name = "sync_wrapper" 1195 | version = "1.0.2" 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" 1197 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1198 | 1199 | [[package]] 1200 | name = "synstructure" 1201 | version = "0.13.1" 1202 | source = "registry+https://github.com/rust-lang/crates.io-index" 1203 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 1204 | dependencies = [ 1205 | "proc-macro2", 1206 | "quote", 1207 | "syn", 1208 | ] 1209 | 1210 | [[package]] 1211 | name = "tempfile" 1212 | version = "3.19.1" 1213 | source = "registry+https://github.com/rust-lang/crates.io-index" 1214 | checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" 1215 | dependencies = [ 1216 | "fastrand", 1217 | "getrandom", 1218 | "once_cell", 1219 | "rustix", 1220 | "windows-sys 0.59.0", 1221 | ] 1222 | 1223 | [[package]] 1224 | name = "thiserror" 1225 | version = "2.0.11" 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" 1227 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 1228 | dependencies = [ 1229 | "thiserror-impl", 1230 | ] 1231 | 1232 | [[package]] 1233 | name = "thiserror-impl" 1234 | version = "2.0.11" 1235 | source = "registry+https://github.com/rust-lang/crates.io-index" 1236 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 1237 | dependencies = [ 1238 | "proc-macro2", 1239 | "quote", 1240 | "syn", 1241 | ] 1242 | 1243 | [[package]] 1244 | name = "time" 1245 | version = "0.3.37" 1246 | source = "registry+https://github.com/rust-lang/crates.io-index" 1247 | checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" 1248 | dependencies = [ 1249 | "deranged", 1250 | "itoa", 1251 | "num-conv", 1252 | "powerfmt", 1253 | "serde", 1254 | "time-core", 1255 | "time-macros", 1256 | ] 1257 | 1258 | [[package]] 1259 | name = "time-core" 1260 | version = "0.1.2" 1261 | source = "registry+https://github.com/rust-lang/crates.io-index" 1262 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 1263 | 1264 | [[package]] 1265 | name = "time-macros" 1266 | version = "0.2.19" 1267 | source = "registry+https://github.com/rust-lang/crates.io-index" 1268 | checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" 1269 | dependencies = [ 1270 | "num-conv", 1271 | "time-core", 1272 | ] 1273 | 1274 | [[package]] 1275 | name = "tinystr" 1276 | version = "0.7.6" 1277 | source = "registry+https://github.com/rust-lang/crates.io-index" 1278 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 1279 | dependencies = [ 1280 | "displaydoc", 1281 | "zerovec", 1282 | ] 1283 | 1284 | [[package]] 1285 | name = "tokio" 1286 | version = "1.44.2" 1287 | source = "registry+https://github.com/rust-lang/crates.io-index" 1288 | checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" 1289 | dependencies = [ 1290 | "backtrace", 1291 | "bytes", 1292 | "libc", 1293 | "mio", 1294 | "parking_lot", 1295 | "pin-project-lite", 1296 | "signal-hook-registry", 1297 | "socket2", 1298 | "tokio-macros", 1299 | "windows-sys 0.52.0", 1300 | ] 1301 | 1302 | [[package]] 1303 | name = "tokio-macros" 1304 | version = "2.5.0" 1305 | source = "registry+https://github.com/rust-lang/crates.io-index" 1306 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 1307 | dependencies = [ 1308 | "proc-macro2", 1309 | "quote", 1310 | "syn", 1311 | ] 1312 | 1313 | [[package]] 1314 | name = "tower" 1315 | version = "0.5.2" 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" 1317 | checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 1318 | dependencies = [ 1319 | "futures-core", 1320 | "futures-util", 1321 | "pin-project-lite", 1322 | "sync_wrapper", 1323 | "tokio", 1324 | "tower-layer", 1325 | "tower-service", 1326 | "tracing", 1327 | ] 1328 | 1329 | [[package]] 1330 | name = "tower-layer" 1331 | version = "0.3.3" 1332 | source = "registry+https://github.com/rust-lang/crates.io-index" 1333 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1334 | 1335 | [[package]] 1336 | name = "tower-service" 1337 | version = "0.3.3" 1338 | source = "registry+https://github.com/rust-lang/crates.io-index" 1339 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1340 | 1341 | [[package]] 1342 | name = "tracing" 1343 | version = "0.1.41" 1344 | source = "registry+https://github.com/rust-lang/crates.io-index" 1345 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1346 | dependencies = [ 1347 | "log", 1348 | "pin-project-lite", 1349 | "tracing-core", 1350 | ] 1351 | 1352 | [[package]] 1353 | name = "tracing-core" 1354 | version = "0.1.33" 1355 | source = "registry+https://github.com/rust-lang/crates.io-index" 1356 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 1357 | dependencies = [ 1358 | "once_cell", 1359 | ] 1360 | 1361 | [[package]] 1362 | name = "try-lock" 1363 | version = "0.2.5" 1364 | source = "registry+https://github.com/rust-lang/crates.io-index" 1365 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1366 | 1367 | [[package]] 1368 | name = "unicode-ident" 1369 | version = "1.0.17" 1370 | source = "registry+https://github.com/rust-lang/crates.io-index" 1371 | checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" 1372 | 1373 | [[package]] 1374 | name = "url" 1375 | version = "2.5.4" 1376 | source = "registry+https://github.com/rust-lang/crates.io-index" 1377 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 1378 | dependencies = [ 1379 | "form_urlencoded", 1380 | "idna", 1381 | "percent-encoding", 1382 | ] 1383 | 1384 | [[package]] 1385 | name = "utf16_iter" 1386 | version = "1.0.5" 1387 | source = "registry+https://github.com/rust-lang/crates.io-index" 1388 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 1389 | 1390 | [[package]] 1391 | name = "utf8_iter" 1392 | version = "1.0.4" 1393 | source = "registry+https://github.com/rust-lang/crates.io-index" 1394 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1395 | 1396 | [[package]] 1397 | name = "utf8parse" 1398 | version = "0.2.2" 1399 | source = "registry+https://github.com/rust-lang/crates.io-index" 1400 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1401 | 1402 | [[package]] 1403 | name = "version_check" 1404 | version = "0.9.5" 1405 | source = "registry+https://github.com/rust-lang/crates.io-index" 1406 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1407 | 1408 | [[package]] 1409 | name = "want" 1410 | version = "0.3.1" 1411 | source = "registry+https://github.com/rust-lang/crates.io-index" 1412 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1413 | dependencies = [ 1414 | "try-lock", 1415 | ] 1416 | 1417 | [[package]] 1418 | name = "wasi" 1419 | version = "0.11.0+wasi-snapshot-preview1" 1420 | source = "registry+https://github.com/rust-lang/crates.io-index" 1421 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1422 | 1423 | [[package]] 1424 | name = "wasi" 1425 | version = "0.13.3+wasi-0.2.2" 1426 | source = "registry+https://github.com/rust-lang/crates.io-index" 1427 | checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 1428 | dependencies = [ 1429 | "wit-bindgen-rt", 1430 | ] 1431 | 1432 | [[package]] 1433 | name = "windows-sys" 1434 | version = "0.52.0" 1435 | source = "registry+https://github.com/rust-lang/crates.io-index" 1436 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1437 | dependencies = [ 1438 | "windows-targets", 1439 | ] 1440 | 1441 | [[package]] 1442 | name = "windows-sys" 1443 | version = "0.59.0" 1444 | source = "registry+https://github.com/rust-lang/crates.io-index" 1445 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1446 | dependencies = [ 1447 | "windows-targets", 1448 | ] 1449 | 1450 | [[package]] 1451 | name = "windows-targets" 1452 | version = "0.52.6" 1453 | source = "registry+https://github.com/rust-lang/crates.io-index" 1454 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1455 | dependencies = [ 1456 | "windows_aarch64_gnullvm", 1457 | "windows_aarch64_msvc", 1458 | "windows_i686_gnu", 1459 | "windows_i686_gnullvm", 1460 | "windows_i686_msvc", 1461 | "windows_x86_64_gnu", 1462 | "windows_x86_64_gnullvm", 1463 | "windows_x86_64_msvc", 1464 | ] 1465 | 1466 | [[package]] 1467 | name = "windows_aarch64_gnullvm" 1468 | version = "0.52.6" 1469 | source = "registry+https://github.com/rust-lang/crates.io-index" 1470 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1471 | 1472 | [[package]] 1473 | name = "windows_aarch64_msvc" 1474 | version = "0.52.6" 1475 | source = "registry+https://github.com/rust-lang/crates.io-index" 1476 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1477 | 1478 | [[package]] 1479 | name = "windows_i686_gnu" 1480 | version = "0.52.6" 1481 | source = "registry+https://github.com/rust-lang/crates.io-index" 1482 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1483 | 1484 | [[package]] 1485 | name = "windows_i686_gnullvm" 1486 | version = "0.52.6" 1487 | source = "registry+https://github.com/rust-lang/crates.io-index" 1488 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1489 | 1490 | [[package]] 1491 | name = "windows_i686_msvc" 1492 | version = "0.52.6" 1493 | source = "registry+https://github.com/rust-lang/crates.io-index" 1494 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1495 | 1496 | [[package]] 1497 | name = "windows_x86_64_gnu" 1498 | version = "0.52.6" 1499 | source = "registry+https://github.com/rust-lang/crates.io-index" 1500 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1501 | 1502 | [[package]] 1503 | name = "windows_x86_64_gnullvm" 1504 | version = "0.52.6" 1505 | source = "registry+https://github.com/rust-lang/crates.io-index" 1506 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1507 | 1508 | [[package]] 1509 | name = "windows_x86_64_msvc" 1510 | version = "0.52.6" 1511 | source = "registry+https://github.com/rust-lang/crates.io-index" 1512 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1513 | 1514 | [[package]] 1515 | name = "wit-bindgen-rt" 1516 | version = "0.33.0" 1517 | source = "registry+https://github.com/rust-lang/crates.io-index" 1518 | checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 1519 | dependencies = [ 1520 | "bitflags", 1521 | ] 1522 | 1523 | [[package]] 1524 | name = "write16" 1525 | version = "1.0.0" 1526 | source = "registry+https://github.com/rust-lang/crates.io-index" 1527 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 1528 | 1529 | [[package]] 1530 | name = "writeable" 1531 | version = "0.5.5" 1532 | source = "registry+https://github.com/rust-lang/crates.io-index" 1533 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 1534 | 1535 | [[package]] 1536 | name = "yansi" 1537 | version = "1.0.1" 1538 | source = "registry+https://github.com/rust-lang/crates.io-index" 1539 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 1540 | 1541 | [[package]] 1542 | name = "yoke" 1543 | version = "0.7.5" 1544 | source = "registry+https://github.com/rust-lang/crates.io-index" 1545 | checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 1546 | dependencies = [ 1547 | "serde", 1548 | "stable_deref_trait", 1549 | "yoke-derive", 1550 | "zerofrom", 1551 | ] 1552 | 1553 | [[package]] 1554 | name = "yoke-derive" 1555 | version = "0.7.5" 1556 | source = "registry+https://github.com/rust-lang/crates.io-index" 1557 | checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 1558 | dependencies = [ 1559 | "proc-macro2", 1560 | "quote", 1561 | "syn", 1562 | "synstructure", 1563 | ] 1564 | 1565 | [[package]] 1566 | name = "zerocopy" 1567 | version = "0.7.35" 1568 | source = "registry+https://github.com/rust-lang/crates.io-index" 1569 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 1570 | dependencies = [ 1571 | "byteorder", 1572 | "zerocopy-derive 0.7.35", 1573 | ] 1574 | 1575 | [[package]] 1576 | name = "zerocopy" 1577 | version = "0.8.21" 1578 | source = "registry+https://github.com/rust-lang/crates.io-index" 1579 | checksum = "dcf01143b2dd5d134f11f545cf9f1431b13b749695cb33bcce051e7568f99478" 1580 | dependencies = [ 1581 | "zerocopy-derive 0.8.21", 1582 | ] 1583 | 1584 | [[package]] 1585 | name = "zerocopy-derive" 1586 | version = "0.7.35" 1587 | source = "registry+https://github.com/rust-lang/crates.io-index" 1588 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 1589 | dependencies = [ 1590 | "proc-macro2", 1591 | "quote", 1592 | "syn", 1593 | ] 1594 | 1595 | [[package]] 1596 | name = "zerocopy-derive" 1597 | version = "0.8.21" 1598 | source = "registry+https://github.com/rust-lang/crates.io-index" 1599 | checksum = "712c8386f4f4299382c9abee219bee7084f78fb939d88b6840fcc1320d5f6da2" 1600 | dependencies = [ 1601 | "proc-macro2", 1602 | "quote", 1603 | "syn", 1604 | ] 1605 | 1606 | [[package]] 1607 | name = "zerofrom" 1608 | version = "0.1.6" 1609 | source = "registry+https://github.com/rust-lang/crates.io-index" 1610 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 1611 | dependencies = [ 1612 | "zerofrom-derive", 1613 | ] 1614 | 1615 | [[package]] 1616 | name = "zerofrom-derive" 1617 | version = "0.1.6" 1618 | source = "registry+https://github.com/rust-lang/crates.io-index" 1619 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 1620 | dependencies = [ 1621 | "proc-macro2", 1622 | "quote", 1623 | "syn", 1624 | "synstructure", 1625 | ] 1626 | 1627 | [[package]] 1628 | name = "zerovec" 1629 | version = "0.10.4" 1630 | source = "registry+https://github.com/rust-lang/crates.io-index" 1631 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 1632 | dependencies = [ 1633 | "yoke", 1634 | "zerofrom", 1635 | "zerovec-derive", 1636 | ] 1637 | 1638 | [[package]] 1639 | name = "zerovec-derive" 1640 | version = "0.10.3" 1641 | source = "registry+https://github.com/rust-lang/crates.io-index" 1642 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 1643 | dependencies = [ 1644 | "proc-macro2", 1645 | "quote", 1646 | "syn", 1647 | ] 1648 | -------------------------------------------------------------------------------- /src/params.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, UploadFile, Value, parse_json, query_parser::QueryParser}; 2 | use ::serde::de::DeserializeOwned; 3 | use actson::feeder::SliceJsonFeeder; 4 | use axum::{ 5 | body::to_bytes, 6 | extract::{FromRequest, FromRequestParts, Path, Request}, 7 | http::{self}, 8 | }; 9 | use log::debug; 10 | use std::collections::HashMap; 11 | use tempfile::NamedTempFile; 12 | 13 | #[derive(Debug, Default)] 14 | pub struct Params(pub T, pub Vec); 15 | 16 | impl FromRequest for Params 17 | where 18 | T: DeserializeOwned, 19 | S: Send + Sync, 20 | { 21 | type Rejection = crate::Error; 22 | 23 | async fn from_request(req: Request, state: &S) -> Result { 24 | let is_get_or_head = 25 | req.method() == http::Method::GET || req.method() == http::Method::HEAD; 26 | let (mut parts, body) = req.into_parts(); 27 | 28 | let parser = QueryParser::new(None); 29 | let mut merged_params = HashMap::new(); 30 | 31 | // Extract path parameters 32 | if let Ok(Path(params)) = 33 | Path::>::from_request_parts(&mut parts, state).await 34 | { 35 | debug!("params: {:?}", params); 36 | 37 | for (key, value) in params { 38 | parser 39 | .parse_nested_value(&mut merged_params, key.as_str(), Value::xstr(value)) 40 | .map_err(|e| { 41 | Error::DecodeError(format!("Failed to parse path parameters: {}", e)) 42 | })?; 43 | } 44 | } 45 | 46 | debug!("merged path params: {:?}", merged_params); 47 | debug!("parts.uri: {:?}", parts.uri); 48 | debug!("parts.uri.query(): {:?}", parts.uri.query()); 49 | 50 | // Extract query parameters from URI 51 | if let Some(query) = parts.uri.query() { 52 | parser 53 | .parse_nested_query_into(&mut merged_params, query) 54 | .map_err(|e| { 55 | Error::DecodeError(format!("Failed to parse query parameters: {}", e)) 56 | })?; 57 | } 58 | 59 | debug!("merged query params: {:?}", merged_params); 60 | 61 | let mut temp_files = Vec::new(); 62 | debug!( 63 | "Content-Type: {:?}", 64 | parts.headers.get(http::header::CONTENT_TYPE) 65 | ); 66 | if let Some(content_type) = parts.headers.get(http::header::CONTENT_TYPE) { 67 | debug!("Content-Type: {:?}", content_type); 68 | if let Ok(content_type) = content_type.to_str() { 69 | match content_type { 70 | ct if ct.starts_with("application/json") => { 71 | let bytes = to_bytes(body, usize::MAX).await.map_err(|e| { 72 | debug!("Failed to read JSON request body: {}", e); 73 | Error::DecodeError(format!("Failed to read JSON request body: {}", e)) 74 | })?; 75 | let feeder = SliceJsonFeeder::new(&bytes); 76 | let value = parse_json(feeder)?; 77 | debug!("parsed json: {:#?}", value); 78 | merged_params = value.merge_into(merged_params).map_err(|e| { 79 | debug!("Failed to merge JSON data: {e:?}"); 80 | Error::DecodeError(format!("Failed to merge JSON data: {e:?}")) 81 | })?; 82 | debug!("merged json: {:#?}", merged_params); 83 | } 84 | ct if ct.starts_with("application/x-www-form-urlencoded") => { 85 | if !is_get_or_head { 86 | let bytes = to_bytes(body, usize::MAX).await.map_err(|e| { 87 | Error::ReadError(format!( 88 | "Failed to read form-urlencoded request body: {e}" 89 | )) 90 | })?; 91 | parser 92 | .parse_nested_query_into( 93 | &mut merged_params, 94 | String::from_utf8_lossy(&bytes).as_ref(), 95 | ) 96 | .map_err(|e| { 97 | Error::DecodeError(format!( 98 | "Failed to parse form-urlencoded body: {}", 99 | e 100 | )) 101 | })? 102 | } 103 | } 104 | ct if ct.starts_with("multipart/form-data") => { 105 | let boundary = multer::parse_boundary(content_type).map_err(|e| { 106 | debug!("Failed to parse multipart boundary: {}", e); 107 | Error::DecodeError(format!("Failed to parse multipart boundary: {e}")) 108 | })?; 109 | let mut multipart = 110 | multer::Multipart::new(body.into_data_stream(), boundary); 111 | 112 | while let Some(mut field) = multipart.next_field().await.map_err(|e| { 113 | debug!("Failed to read multipart field: {}", e); 114 | Error::ReadError(format!("Failed to read multipart field: {e}",)) 115 | })? { 116 | let content_type = field 117 | .content_type() 118 | .map(|ct| ct.to_string()) 119 | .unwrap_or_else(|| "application/octet-stream".to_string()); 120 | if content_type == "application/json" { 121 | let name = field.name().map(|s| s.to_string()); 122 | let bytes = field.bytes().await.map_err(|e| { 123 | debug!("Failed to read JSON field bytes: {}", e); 124 | Error::ReadError(format!( 125 | "Failed to read JSON field bytes: {e}", 126 | )) 127 | })?; 128 | debug!( 129 | "JSON field bytes: {}", 130 | String::from_utf8(bytes.to_vec()).unwrap() 131 | ); 132 | let feeder = SliceJsonFeeder::new(&bytes); 133 | let value = parse_json(feeder)?; 134 | debug!("Parsed JSON field: {:#?}", value); 135 | let name = name.unwrap_or_default(); 136 | if name.is_empty() { 137 | merged_params = 138 | value.merge_into(merged_params).map_err(|e| { 139 | debug!("Failed to merge JSON field: {e:?}"); 140 | Error::DecodeError(format!( 141 | "Failed to merge JSON field: {e:?}", 142 | )) 143 | })?; 144 | } else { 145 | parser 146 | .parse_nested_value( 147 | &mut merged_params, 148 | name.as_str(), 149 | value, 150 | ) 151 | .map_err(|e| { 152 | Error::DecodeError(format!( 153 | "Failed to parse JSON field: {}", 154 | e 155 | )) 156 | })?; 157 | } 158 | 159 | debug!("Merged JSON field: {:#?}", merged_params); 160 | continue; 161 | } 162 | if let Some(name) = field.name() { 163 | let name = name.to_string(); 164 | 165 | // Check if this is a file upload field 166 | if field.file_name().is_some() { 167 | // Handle file upload 168 | let temp_file = NamedTempFile::new().map_err(|e| { 169 | Error::IOError(format!("Failed to create temp file: {e}",)) 170 | })?; 171 | debug!("Created temp file at: {:?}", temp_file.path()); 172 | 173 | let mut file = tokio::fs::OpenOptions::new() 174 | .write(true) 175 | .open(temp_file.path()) 176 | .await 177 | .map_err(|e| { 178 | debug!("Failed to open temp file for writing: {}", e); 179 | Error::IOError( 180 | format!("Failed to open temp file: {e}",), 181 | ) 182 | })?; 183 | 184 | let mut total_bytes = 0; 185 | while let Some(chunk) = field.chunk().await.map_err(|e| { 186 | debug!("Failed to read multipart field chunk: {}", e); 187 | Error::ReadError(format!( 188 | "Failed to read multipart field chunk: {e}", 189 | )) 190 | })? { 191 | total_bytes += chunk.len(); 192 | debug!("Writing chunk of size {} bytes", chunk.len()); 193 | tokio::io::copy(&mut &*chunk, &mut file).await.map_err( 194 | |e| { 195 | debug!("Failed to write chunk to temp file: {}", e); 196 | Error::IOError(format!( 197 | "Failed to write to temp file: {e}", 198 | )) 199 | }, 200 | )?; 201 | } 202 | 203 | // Sync the file to disk 204 | file.sync_all().await.map_err(|e| { 205 | debug!("Failed to sync temp file: {}", e); 206 | Error::IOError(format!("Failed to sync temp file: {e}",)) 207 | })?; 208 | 209 | debug!("Total bytes written to file: {}", total_bytes); 210 | 211 | let file = Value::UploadFile(UploadFile { 212 | name: field.file_name().unwrap().to_string(), 213 | content_type: field 214 | .content_type() 215 | .map(|ct| ct.to_string()) 216 | .unwrap_or_else(|| { 217 | "application/octet-stream".to_string() 218 | }), 219 | temp_file_path: temp_file 220 | .path() 221 | .to_string_lossy() 222 | .to_string(), 223 | }); 224 | parser 225 | .parse_nested_value(&mut merged_params, name.as_str(), file) 226 | .map_err(|e| { 227 | Error::DecodeError(format!( 228 | "Failed to parse file upload field: {}", 229 | e 230 | )) 231 | })?; 232 | 233 | // Store the temp file 234 | temp_files.push(temp_file); 235 | } else { 236 | // Handle text field 237 | let value = field.text().await.map_err(|e| { 238 | debug!("Failed to read text field: {}", e); 239 | Error::ReadError(format!("Failed to read text field: {e}",)) 240 | })?; 241 | parser 242 | .parse_nested_value( 243 | &mut merged_params, 244 | name.as_str(), 245 | Value::xstr(value), 246 | ) 247 | .map_err(|e| { 248 | Error::DecodeError(format!( 249 | "Failed to parse text field: {}", 250 | e 251 | )) 252 | })?; 253 | } 254 | } 255 | } 256 | } 257 | ct => { 258 | debug!("Unhandled content type: {}", ct); 259 | } 260 | } 261 | } 262 | } 263 | 264 | debug!("merged: {:?}", merged_params); 265 | T::deserialize(Value::Object(merged_params)) 266 | .map_err(|e| Error::DecodeError(format!("Failed to deserialize parameters: {e}"))) 267 | .map(|payload| Params(payload, temp_files)) 268 | } 269 | } 270 | 271 | #[cfg(test)] 272 | mod tests { 273 | use std::collections::HashMap; 274 | 275 | use super::*; 276 | use ::serde::{Deserialize, Serialize}; 277 | use axum::{ 278 | Json, Router, 279 | body::Body, 280 | extract::{FromRequest, Request}, 281 | http::StatusCode, 282 | http::{self, HeaderValue}, 283 | response::IntoResponse, 284 | routing::{get, post}, 285 | }; 286 | use axum_test::{ 287 | TestServer, 288 | multipart::{MultipartForm, Part}, 289 | }; 290 | use log::debug; 291 | use serde_json::json; 292 | use tokio::io::AsyncReadExt; 293 | 294 | pub fn setup() { 295 | let _ = env_logger::builder().is_test(true).try_init(); 296 | } 297 | 298 | #[derive(Debug, Deserialize, Serialize, PartialEq)] 299 | struct TestParams { 300 | id: i32, 301 | name: String, 302 | #[serde(default)] 303 | extra: Option, 304 | } 305 | 306 | #[derive(Debug, Serialize, Deserialize)] 307 | struct UploadFileResponse { 308 | name: String, 309 | content_type: String, 310 | content: String, 311 | } 312 | 313 | #[axum::debug_handler] 314 | async fn test_params_handler(Params(test, _): Params) -> impl IntoResponse { 315 | (StatusCode::OK, serde_json::to_string(&test).unwrap()) 316 | } 317 | 318 | #[derive(Debug, Deserialize)] 319 | struct FileUploadParams { 320 | title: String, 321 | description: Option, 322 | file: UploadFile, 323 | } 324 | 325 | #[axum::debug_handler] 326 | async fn file_upload_handler(Params(upload, _): Params) -> impl IntoResponse { 327 | let mut temp_file = upload.file.open().await.unwrap(); 328 | debug!( 329 | "Reading file from: {:?}", 330 | temp_file.metadata().await.unwrap() 331 | ); 332 | let mut content = String::new(); 333 | temp_file.read_to_string(&mut content).await.unwrap(); 334 | debug!("Read {} bytes from file", content.len()); 335 | debug!("File content: {:?}", content); 336 | 337 | let response = json!({ 338 | "title": upload.title, 339 | "description": upload.description, 340 | "file_name": upload.file.name, 341 | "file_content": content, 342 | }); 343 | (StatusCode::OK, serde_json::to_string(&response).unwrap()) 344 | } 345 | 346 | #[derive(Debug, Serialize, Deserialize)] 347 | struct Attachment { 348 | file: UploadFile, 349 | name: String, 350 | } 351 | 352 | #[derive(Debug, Serialize, Deserialize)] 353 | struct CreatePostParams { 354 | title: String, 355 | content: String, 356 | tags: Vec, 357 | cover: UploadFile, 358 | attachments: Vec, 359 | } 360 | 361 | #[derive(Debug, Serialize, Deserialize)] 362 | struct AttachmentResponse { 363 | name: String, 364 | content_type: String, 365 | content: String, 366 | } 367 | 368 | #[derive(Debug, Serialize, Deserialize)] 369 | struct CreatePostResponse { 370 | title: String, 371 | content: String, 372 | tags: Vec, 373 | cover: UploadFileResponse, 374 | attachments: Vec, 375 | } 376 | 377 | #[axum::debug_handler] 378 | async fn test_nested_params_handler( 379 | Params(post, _): Params, 380 | ) -> Result, Error> { 381 | let mut cover_file = post.cover.open().await.unwrap(); 382 | let mut cover_content = String::new(); 383 | cover_file.read_to_string(&mut cover_content).await.unwrap(); 384 | 385 | let response = CreatePostResponse { 386 | title: post.title, 387 | content: post.content, 388 | tags: post.tags, 389 | cover: UploadFileResponse { 390 | name: post.cover.name, 391 | content_type: post.cover.content_type, 392 | content: cover_content, 393 | }, 394 | attachments: Vec::new(), 395 | }; 396 | 397 | let attachments = 398 | futures_util::future::join_all(post.attachments.into_iter().map(|a| async { 399 | let mut file = a.file.open().await.unwrap(); 400 | let mut content = String::new(); 401 | file.read_to_string(&mut content).await.unwrap(); 402 | 403 | AttachmentResponse { 404 | name: a.name, 405 | content_type: a.file.content_type, 406 | content, 407 | } 408 | })) 409 | .await; 410 | 411 | let mut response = response; 412 | response.attachments = attachments; 413 | 414 | Ok(Json(response)) 415 | } 416 | 417 | #[tokio::test] 418 | async fn test_path_params() { 419 | let app = Router::new().route("/users/{id}", get(test_params_handler)); 420 | let server = TestServer::new(app).unwrap(); 421 | 422 | let response = server 423 | .get("/users/123") 424 | .add_query_params(&[("name", "test")]) 425 | .await; 426 | println!("response: {:?}", response); 427 | assert_eq!(response.status_code(), StatusCode::OK); 428 | 429 | let body = response.text(); 430 | let params: TestParams = serde_json::from_str(&body).unwrap(); 431 | assert_eq!(params.id, 123); 432 | assert_eq!(params.name, "test"); 433 | assert_eq!(params.extra, None); 434 | } 435 | 436 | #[tokio::test] 437 | async fn test_json_body() { 438 | let app = Router::new().route("/api/test", post(test_params_handler)); 439 | let server = TestServer::new(app).unwrap(); 440 | 441 | let json_data = json!({ 442 | "id": 123, 443 | "name": "test", 444 | "extra": "data" 445 | }); 446 | 447 | let response = server.post("/api/test").json(&json_data).await; 448 | assert_eq!(response.status_code(), StatusCode::OK); 449 | 450 | let body = response.text(); 451 | let params: TestParams = serde_json::from_str(&body).unwrap(); 452 | assert_eq!(params.id, 123); 453 | assert_eq!(params.name, "test"); 454 | assert_eq!(params.extra, Some("data".to_string())); 455 | } 456 | 457 | #[tokio::test] 458 | async fn test_form_data() { 459 | let app = Router::new().route("/api/test", post(test_params_handler)); 460 | let server = TestServer::new(app).unwrap(); 461 | 462 | let response = server 463 | .post("/api/test") 464 | .form(&[("id", "123"), ("name", "test"), ("extra", "form_data")]) 465 | .await; 466 | assert_eq!(response.status_code(), StatusCode::OK); 467 | 468 | let body = response.text(); 469 | let params: TestParams = serde_json::from_str(&body).unwrap(); 470 | assert_eq!(params.id, 123); 471 | assert_eq!(params.name, "test"); 472 | assert_eq!(params.extra, Some("form_data".to_string())); 473 | } 474 | 475 | #[tokio::test] 476 | async fn test_multipart_form() { 477 | let app = Router::new().route("/api/upload", post(file_upload_handler)); 478 | let server = TestServer::new(app).unwrap(); 479 | 480 | let test_content = b"Hello, World!"; 481 | let test_content_str = String::from_utf8_lossy(test_content).to_string(); 482 | 483 | let response = server 484 | .post("/api/upload") 485 | .add_header( 486 | axum::http::header::CONTENT_TYPE, 487 | HeaderValue::from_static("multipart/form-data; boundary=X-BOUNDARY"), 488 | ) 489 | .multipart( 490 | MultipartForm::new() 491 | .add_text("title", "Test Upload") 492 | .add_text("description", "A test file upload") 493 | .add_part( 494 | "file", 495 | Part::bytes(test_content.to_vec()).file_name("test.txt"), 496 | ), 497 | ) 498 | .await; 499 | 500 | assert_eq!(response.status_code(), StatusCode::OK); 501 | let body = response.text(); 502 | let result: serde_json::Value = serde_json::from_str(&body).unwrap(); 503 | assert_eq!(result["title"], "Test Upload"); 504 | assert_eq!(result["description"], "A test file upload"); 505 | assert_eq!(result["file_name"], "test.txt"); 506 | assert_eq!(result["file_content"], test_content_str); 507 | } 508 | 509 | #[tokio::test] 510 | async fn test_combined_params() { 511 | let app = Router::new().route("/users/{id}", post(test_params_handler)); 512 | let server = TestServer::new(app).unwrap(); 513 | 514 | let json_data = json!({ 515 | "name": "test" 516 | }); 517 | 518 | let response = server 519 | .post("/users/123") 520 | .add_query_params(&[("extra", "query_param")]) 521 | .json(&json_data) 522 | .await; 523 | assert_eq!(response.status_code(), StatusCode::OK); 524 | 525 | let body = response.text(); 526 | let params: TestParams = serde_json::from_str(&body).unwrap(); 527 | assert_eq!(params.id, 123); 528 | assert_eq!(params.name, "test"); 529 | assert_eq!(params.extra, Some("query_param".to_string())); 530 | } 531 | 532 | #[tokio::test] 533 | async fn test_nested_params_with_file_upload() { 534 | let app = Router::new().route("/api/posts", post(test_nested_params_handler)); 535 | let server = TestServer::new(app).unwrap(); 536 | 537 | // Create test file content 538 | let cover_content = b"Cover image content"; 539 | let attachment1_content = b"Attachment 1 content"; 540 | let attachment2_content = b"Attachment 2 content"; 541 | 542 | // Create multipart form 543 | let response = server 544 | .post("/api/posts") 545 | .add_header( 546 | axum::http::header::CONTENT_TYPE, 547 | HeaderValue::from_static("multipart/form-data; boundary=X-BOUNDARY"), 548 | ) 549 | .multipart( 550 | MultipartForm::new() 551 | .add_text("title", "Test Post") 552 | .add_text("content", "This is a test post content") 553 | .add_text("tags[]", "tag1") 554 | .add_text("tags[]", "tag2") 555 | .add_text("tags[]", "tag3") 556 | .add_part( 557 | "cover", 558 | Part::bytes(cover_content.to_vec()) 559 | .file_name("cover.jpg") 560 | .mime_type("image/jpeg"), 561 | ) 562 | .add_text("attachments[][name]", "First attachment") 563 | .add_part( 564 | "attachments[][file]", 565 | Part::bytes(attachment1_content.to_vec()) 566 | .file_name("attachment1.txt") 567 | .mime_type("text/plain"), 568 | ) 569 | .add_text("attachments[][name]", "Second attachment") 570 | .add_part( 571 | "attachments[][file]", 572 | Part::bytes(attachment2_content.to_vec()) 573 | .file_name("attachment2.txt") 574 | .mime_type("text/plain"), 575 | ), 576 | ) 577 | .await; 578 | 579 | assert_eq!(response.status_code(), StatusCode::OK); 580 | 581 | let body: CreatePostResponse = serde_json::from_str(&response.text()).unwrap(); 582 | 583 | // Verify the response 584 | assert_eq!(body.title, "Test Post"); 585 | assert_eq!(body.content, "This is a test post content"); 586 | assert_eq!(body.tags, vec!["tag1", "tag2", "tag3"]); 587 | 588 | // Verify cover file 589 | assert_eq!(body.cover.name, "cover.jpg"); 590 | assert_eq!(body.cover.content_type, "image/jpeg"); 591 | assert_eq!(body.cover.content, String::from_utf8_lossy(cover_content)); 592 | 593 | // Verify attachments 594 | assert_eq!(body.attachments.len(), 2); 595 | 596 | let attachment1 = &body.attachments[0]; 597 | assert_eq!(attachment1.name, "First attachment"); 598 | assert_eq!(attachment1.content_type, "text/plain"); 599 | assert_eq!( 600 | attachment1.content, 601 | String::from_utf8_lossy(attachment1_content) 602 | ); 603 | 604 | let attachment2 = &body.attachments[1]; 605 | assert_eq!(attachment2.name, "Second attachment"); 606 | assert_eq!(attachment2.content_type, "text/plain"); 607 | assert_eq!( 608 | attachment2.content, 609 | String::from_utf8_lossy(attachment2_content) 610 | ); 611 | } 612 | 613 | #[derive(Debug, Serialize, Deserialize)] 614 | struct MixedAttachment { 615 | name: String, 616 | description: String, 617 | file: Option, 618 | } 619 | 620 | #[derive(Debug, Serialize, Deserialize)] 621 | struct MixedPostParams { 622 | title: String, 623 | content: String, 624 | tags: Option>, 625 | attachments: Option>, 626 | author: Option, 627 | status: Option, 628 | metadata: Option>, 629 | } 630 | 631 | #[derive(Debug, Serialize, Deserialize)] 632 | struct MixedAttachmentResponse { 633 | name: String, 634 | description: String, 635 | file_size: u64, 636 | } 637 | 638 | #[derive(Debug, Serialize, Deserialize)] 639 | struct MixedPostResponse { 640 | message: String, 641 | title: String, 642 | content: String, 643 | tags: Vec, 644 | author: String, 645 | status: String, 646 | metadata: HashMap, 647 | attachments: Vec, 648 | } 649 | 650 | #[tokio::test] 651 | async fn test_mixed_create_post() { 652 | setup(); 653 | use tokio::io::AsyncReadExt; 654 | 655 | let app = Router::new().route( 656 | "/posts/{category}", 657 | post(|params: Params| async move { 658 | debug!("params: {:#?}", params); 659 | let MixedPostParams { 660 | title, 661 | content, 662 | tags, 663 | attachments, 664 | author, 665 | status, 666 | metadata, 667 | } = params.0; 668 | 669 | let mut response = MixedPostResponse { 670 | message: "Success".to_string(), 671 | title, 672 | content, 673 | tags: tags.unwrap_or_default(), 674 | author: author.unwrap_or_default(), 675 | status: status.unwrap_or_default(), 676 | metadata: metadata.unwrap_or_default(), 677 | attachments: Vec::new(), 678 | }; 679 | 680 | if let Some(attachments) = attachments { 681 | response.attachments = 682 | futures_util::future::join_all(attachments.into_iter().map(|a| async { 683 | let size = if let Some(file) = a.file { 684 | let mut f = file.open().await.unwrap(); 685 | let mut content = Vec::new(); 686 | f.read_to_end(&mut content).await.unwrap(); 687 | content.len() as u64 688 | } else { 689 | 0 690 | }; 691 | 692 | MixedAttachmentResponse { 693 | name: a.name, 694 | description: a.description, 695 | file_size: size, 696 | } 697 | })) 698 | .await; 699 | } 700 | 701 | Ok::, Error>(Json(response)) 702 | }), 703 | ); 704 | 705 | let server = TestServer::new(app).unwrap(); 706 | 707 | // Prepare base post data in empty-named JSON part 708 | let base_json = r#"{ 709 | "title": "Mixed Test Post", 710 | "content": "This is a test post with mixed data sources", 711 | "tags": ["rust", "axum", "test"] 712 | }"#; 713 | 714 | // Create multipart form with mixed data 715 | let form = MultipartForm::new() 716 | // Add base data as empty-named JSON part 717 | .add_part("", Part::text(base_json).mime_type("application/json")) 718 | // Add metadata fields individually 719 | .add_part("metadata[version]", Part::text("2.0")) 720 | .add_part("metadata[visibility]", Part::text("public")) 721 | .add_part("metadata[created_at]", Part::text("2024-12-29")) 722 | // Add first attachment with file and metadata 723 | .add_part( 724 | "attachments[][file]", 725 | Part::bytes(vec![1, 2, 3, 4]) 726 | .file_name("test1.bin") 727 | .mime_type("application/octet-stream"), 728 | ) 729 | .add_part("attachments[][name]", Part::text("Test Attachment 1")) 730 | .add_part( 731 | "attachments[][description]", 732 | Part::text("First test attachment"), 733 | ) 734 | // Add second attachment with file and metadata 735 | .add_part( 736 | "attachments[][file]", 737 | Part::bytes(vec![5, 6, 7, 8, 9]) 738 | .file_name("test2.bin") 739 | .mime_type("application/octet-stream"), 740 | ) 741 | .add_part("attachments[][name]", Part::text("Test Attachment 2")) 742 | .add_part( 743 | "attachments[][description]", 744 | Part::text("Second test attachment"), 745 | ); 746 | 747 | // Add headers for author and status 748 | let response = server 749 | .post("/posts/tech?author=test_user&status=draft") 750 | .add_header( 751 | axum::http::header::CONTENT_TYPE, 752 | HeaderValue::from_static("multipart/form-data"), 753 | ) 754 | .multipart(form) 755 | .await; 756 | 757 | debug!("Request URL: {}", response.request_url()); 758 | debug!("Request headers: {:?}", response.headers()); 759 | debug!("Response status: {}", response.status_code()); 760 | debug!( 761 | "Response body: {}", 762 | String::from_utf8_lossy(&response.as_bytes()) 763 | ); 764 | 765 | assert_eq!(response.status_code(), StatusCode::OK); 766 | 767 | let body: MixedPostResponse = response.json(); 768 | 769 | // Verify base data from empty-named JSON part 770 | assert_eq!(body.title, "Mixed Test Post"); 771 | assert_eq!(body.content, "This is a test post with mixed data sources"); 772 | assert_eq!(body.tags, vec!["rust", "axum", "test"]); 773 | 774 | // Verify data from query parameters 775 | assert_eq!(body.author, "test_user"); 776 | assert_eq!(body.status, "draft"); 777 | 778 | // Verify metadata from named JSON part 779 | assert_eq!(body.metadata.get("version").unwrap(), "2.0"); 780 | assert_eq!(body.metadata.get("visibility").unwrap(), "public"); 781 | assert_eq!(body.metadata.get("created_at").unwrap(), "2024-12-29"); 782 | 783 | // Verify attachments from multipart form 784 | assert_eq!(body.attachments.len(), 2); 785 | 786 | let attachment1 = &body.attachments[0]; 787 | assert_eq!(attachment1.name, "Test Attachment 1"); 788 | assert_eq!(attachment1.description, "First test attachment"); 789 | assert_eq!(attachment1.file_size, 4); 790 | 791 | let attachment2 = &body.attachments[1]; 792 | assert_eq!(attachment2.name, "Test Attachment 2"); 793 | assert_eq!(attachment2.description, "Second test attachment"); 794 | assert_eq!(attachment2.file_size, 5); 795 | } 796 | 797 | #[derive(Debug, Serialize, Deserialize)] 798 | struct ComplexParams { 799 | // Path params 800 | user_id: i32, 801 | // Query params 802 | filter: String, 803 | page: Option, 804 | // JSON body 805 | data: ComplexData, 806 | // Multipart form 807 | avatar: UploadFile, 808 | profile: Option, 809 | } 810 | 811 | #[derive(Debug, Serialize, Deserialize)] 812 | struct ComplexData { 813 | title: String, 814 | tags: Vec, 815 | version_number: f64, 816 | metadata: HashMap, 817 | } 818 | 819 | #[derive(Debug, Serialize, Deserialize)] 820 | struct ComplexResponse { 821 | message: String, 822 | user_id: i32, 823 | filter: String, 824 | page: Option, 825 | title: String, 826 | tags: Vec, 827 | version_number: f64, 828 | metadata: HashMap, 829 | avatar_size: u64, 830 | profile: Option, 831 | } 832 | 833 | async fn complex_handler( 834 | Params(params, _): Params, 835 | ) -> Result, Error> { 836 | let avatar_file = params 837 | .avatar 838 | .open() 839 | .await 840 | .map_err(|e| Error::ReadError(format!("Failed to open avatar file: {}", e)))?; 841 | let avatar_size = avatar_file 842 | .metadata() 843 | .await 844 | .map_err(|e| Error::ReadError(format!("Failed to get avatar metadata: {}", e)))? 845 | .len(); 846 | 847 | Ok(Json(ComplexResponse { 848 | message: "Success".to_string(), 849 | user_id: params.user_id, 850 | filter: params.filter, 851 | page: params.page, 852 | title: params.data.title, 853 | tags: params.data.tags, 854 | version_number: params.data.version_number, 855 | metadata: params.data.metadata, 856 | avatar_size, 857 | profile: params.profile, 858 | })) 859 | } 860 | 861 | #[tokio::test] 862 | async fn test_complex_params() { 863 | setup(); 864 | let app = Router::new().route("/users/{user_id}", post(complex_handler)); 865 | let server = TestServer::new(app).unwrap(); 866 | 867 | // Prepare multipart form data 868 | let avatar_content = "avatar data".as_bytes(); 869 | let form = MultipartForm::new() 870 | .add_part( 871 | "avatar", 872 | Part::bytes(avatar_content.to_vec()) 873 | .file_name("avatar.jpg") 874 | .mime_type("image/jpeg"), 875 | ) 876 | .add_text("profile", "Test profile") 877 | .add_part( 878 | "data", 879 | Part::text( 880 | r#"{ 881 | "title": "Test Post", 882 | "tags": ["rust", "axum"], 883 | "version_number": 1.0, 884 | "metadata": { 885 | "version": "1.0", 886 | "author": "test" 887 | } 888 | }"#, 889 | ) 890 | .mime_type("application/json"), 891 | ); 892 | 893 | // Build the request with all parameter types 894 | let user_id = 123; 895 | let response = server 896 | .post(&format!("/users/{}", user_id)) 897 | .add_query_param("filter", "active") 898 | .add_query_param("page", "1") 899 | .multipart(form) 900 | .await; 901 | debug!("Request URL: {}", response.request_url()); 902 | debug!("Request headers: {:?}", response.headers()); 903 | debug!("Response status: {}", response.status_code()); 904 | debug!( 905 | "Response body: {}", 906 | String::from_utf8_lossy(&response.as_bytes()) 907 | ); 908 | assert_eq!(response.status_code(), StatusCode::OK); 909 | 910 | let body: ComplexResponse = response.json(); 911 | assert_eq!(body.message, "Success"); 912 | assert_eq!(body.user_id, user_id); 913 | assert_eq!(body.filter, "active"); 914 | assert_eq!(body.page, Some(1)); 915 | assert_eq!(body.title, "Test Post"); 916 | assert_eq!(body.tags, vec!["rust", "axum"]); 917 | assert_eq!(body.metadata.get("version").unwrap(), "1.0"); 918 | assert_eq!(body.metadata.get("author").unwrap(), "test"); 919 | assert_eq!(body.avatar_size, avatar_content.len() as u64); 920 | assert_eq!(body.profile, Some("Test profile".to_string())); 921 | } 922 | 923 | #[tokio::test] 924 | async fn test_json_part() { 925 | setup(); 926 | let app = Router::new().route("/test", post(complex_handler)); 927 | let server = TestServer::new(app).unwrap(); 928 | 929 | // Prepare multipart form data with empty field name 930 | let form = MultipartForm::new() 931 | .add_part( 932 | "", 933 | Part::text( 934 | r#"{ 935 | "data": { 936 | "title": "Test Post", 937 | "tags": ["rust", "axum"], 938 | "version_number": 1.0, 939 | "metadata": { 940 | "version": "1.0", 941 | "author": "test" 942 | } 943 | }, 944 | "profile": "Test profile", 945 | "filter": "active", 946 | "page": 1, 947 | "user_id": 123 948 | }"#, 949 | ) 950 | .mime_type("application/json"), 951 | ) 952 | .add_part( 953 | "avatar", 954 | Part::bytes(vec![1, 2, 3, 4]) 955 | .file_name("test-avatar.bin") 956 | .mime_type("application/octet-stream"), 957 | ); 958 | 959 | let response = server.post("/test").multipart(form).await; 960 | debug!("Request URL: {}", response.request_url()); 961 | debug!("Request headers: {:?}", response.headers()); 962 | debug!("Response status: {}", response.status_code()); 963 | debug!( 964 | "Response body: {}", 965 | String::from_utf8_lossy(&response.as_bytes()) 966 | ); 967 | assert_eq!(response.status_code(), StatusCode::OK); 968 | 969 | let body: ComplexResponse = response.json(); 970 | assert_eq!(body.message, "Success"); 971 | assert_eq!(body.user_id, 123); 972 | assert_eq!(body.filter, "active"); 973 | assert_eq!(body.page, Some(1)); 974 | assert_eq!(body.title, "Test Post"); 975 | assert_eq!(body.tags, vec!["rust", "axum"]); 976 | assert_eq!(body.version_number, 1.0); 977 | assert_eq!(body.metadata.get("version").unwrap(), "1.0"); 978 | assert_eq!(body.metadata.get("author").unwrap(), "test"); 979 | assert_eq!(body.profile, Some("Test profile".to_string())); 980 | assert_eq!(body.avatar_size, 4); 981 | } 982 | 983 | #[derive(Debug, Deserialize)] 984 | struct TestNumbers { 985 | pos_int: u64, 986 | neg_int: i64, 987 | float: f64, 988 | zero: i64, 989 | big_num: u64, 990 | small_float: f64, 991 | exp_num: f64, 992 | } 993 | 994 | #[derive(Debug, Deserialize)] 995 | struct TestMixed { 996 | number: i64, 997 | text: String, 998 | boolean: bool, 999 | opt_val: Option, 1000 | numbers: Vec, 1001 | nested: TestNested, 1002 | } 1003 | 1004 | #[derive(Debug, Deserialize)] 1005 | struct TestNested { 1006 | id: u64, 1007 | name: String, 1008 | } 1009 | 1010 | #[tokio::test] 1011 | async fn test_json_numbers() { 1012 | setup(); 1013 | let json = r#"{ 1014 | "pos_int": 42, 1015 | "neg_int": -42, 1016 | "float": 42.5, 1017 | "zero": 0, 1018 | "big_num": 9007199254740991, 1019 | "small_float": 0.0000123, 1020 | "exp_num": 1.23e5 1021 | }"#; 1022 | 1023 | let req = Request::builder() 1024 | .method(http::Method::POST) 1025 | .header(http::header::CONTENT_TYPE, "application/json") 1026 | .body(Body::from(json)) 1027 | .unwrap(); 1028 | 1029 | let params = Params::::from_request(req, &()).await.unwrap(); 1030 | assert_eq!(params.0.pos_int, 42); 1031 | assert_eq!(params.0.neg_int, -42); 1032 | assert!((params.0.float - 42.5).abs() < f64::EPSILON); 1033 | assert_eq!(params.0.zero, 0); 1034 | assert_eq!(params.0.big_num, 9007199254740991); 1035 | assert!((params.0.small_float - 0.0000123).abs() < f64::EPSILON); 1036 | assert!((params.0.exp_num - 123000.0).abs() < f64::EPSILON); 1037 | } 1038 | 1039 | #[tokio::test] 1040 | async fn test_json_mixed_types() { 1041 | setup(); 1042 | let json = r#"{ 1043 | "number": 42, 1044 | "text": "hello world", 1045 | "boolean": true, 1046 | "opt_val": null, 1047 | "numbers": [1.1, 2.2, 3.3], 1048 | "nested": { 1049 | "id": 1, 1050 | "name": "test" 1051 | } 1052 | }"#; 1053 | 1054 | let req = Request::builder() 1055 | .method(http::Method::POST) 1056 | .header(http::header::CONTENT_TYPE, "application/json") 1057 | .body(Body::from(json)) 1058 | .unwrap(); 1059 | 1060 | let params = Params::::from_request(req, &()).await.unwrap(); 1061 | assert_eq!(params.0.number, 42); 1062 | assert_eq!(params.0.text, "hello world"); 1063 | assert!(params.0.boolean); 1064 | assert!(params.0.opt_val.is_none()); 1065 | assert_eq!(params.0.numbers.len(), 3); 1066 | assert!((params.0.numbers[0] - 1.1).abs() < f64::EPSILON); 1067 | assert!((params.0.numbers[1] - 2.2).abs() < f64::EPSILON); 1068 | assert!((params.0.numbers[2] - 3.3).abs() < f64::EPSILON); 1069 | assert_eq!(params.0.nested.id, 1); 1070 | assert_eq!(params.0.nested.name, "test"); 1071 | } 1072 | 1073 | #[tokio::test] 1074 | async fn test_form_urlencoded_numbers() { 1075 | setup(); 1076 | let form_data = "pos_int=42&neg_int=-42&float=42.5&zero=0&big_num=9007199254740991&small_float=0.0000123&exp_num=123000"; 1077 | 1078 | let req = Request::builder() 1079 | .method(http::Method::POST) 1080 | .header( 1081 | http::header::CONTENT_TYPE, 1082 | "application/x-www-form-urlencoded", 1083 | ) 1084 | .body(Body::from(form_data)) 1085 | .unwrap(); 1086 | 1087 | let params = Params::::from_request(req, &()).await.unwrap(); 1088 | assert_eq!(params.0.pos_int, 42); 1089 | assert_eq!(params.0.neg_int, -42); 1090 | assert!((params.0.float - 42.5).abs() < f64::EPSILON); 1091 | assert_eq!(params.0.zero, 0); 1092 | assert_eq!(params.0.big_num, 9007199254740991); 1093 | assert!((params.0.small_float - 0.0000123).abs() < f64::EPSILON); 1094 | assert!((params.0.exp_num - 123000.0).abs() < f64::EPSILON); 1095 | } 1096 | 1097 | #[tokio::test] 1098 | async fn test_query_params_numbers() { 1099 | setup(); 1100 | let req = Request::builder() 1101 | .method(http::Method::GET) 1102 | .uri("/test?pos_int=42&neg_int=-42&float=42.5&zero=0&big_num=9007199254740991&small_float=0.0000123&exp_num=123000") 1103 | .body(Body::empty()) 1104 | .unwrap(); 1105 | 1106 | let params = Params::::from_request(req, &()).await.unwrap(); 1107 | assert_eq!(params.0.pos_int, 42); 1108 | assert_eq!(params.0.neg_int, -42); 1109 | assert!((params.0.float - 42.5).abs() < f64::EPSILON); 1110 | assert_eq!(params.0.zero, 0); 1111 | assert_eq!(params.0.big_num, 9007199254740991); 1112 | assert!((params.0.small_float - 0.0000123).abs() < f64::EPSILON); 1113 | assert!((params.0.exp_num - 123000.0).abs() < f64::EPSILON); 1114 | } 1115 | 1116 | #[derive(Debug, Deserialize)] 1117 | struct TestEncodedParams { 1118 | #[serde(rename = "foo=1")] 1119 | foo: Option, 1120 | baz: Option, 1121 | } 1122 | 1123 | #[tokio::test] 1124 | async fn test_encoded_path_params() { 1125 | setup(); 1126 | 1127 | let req = Request::builder() 1128 | .method(http::Method::GET) 1129 | .uri("/test?foo%3D1=bar&baz=qux%3D2") 1130 | .body(Body::empty()) 1131 | .unwrap(); 1132 | 1133 | let Params(params, _) = Params::::from_request(req, &()) 1134 | .await 1135 | .unwrap(); 1136 | assert_eq!(params.foo, Some("bar".to_string())); 1137 | assert_eq!(params.baz, Some("qux=2".to_string())); 1138 | } 1139 | 1140 | #[tokio::test] 1141 | async fn test_json_params() { 1142 | setup(); 1143 | let req = Request::builder() 1144 | .method(http::Method::POST) 1145 | .header(http::header::CONTENT_TYPE, "application/json") 1146 | .uri("/test") 1147 | .body(Body::new( 1148 | json!({ 1149 | "foo=1": "bar", 1150 | "baz": "qux=2" 1151 | }) 1152 | .to_string(), 1153 | )) 1154 | .unwrap(); 1155 | 1156 | let Params(params, _) = Params::::from_request(req, &()) 1157 | .await 1158 | .unwrap(); 1159 | assert_eq!(params.foo, Some("bar".to_string())); 1160 | assert_eq!(params.baz, Some("qux=2".to_string())); 1161 | } 1162 | 1163 | #[tokio::test] 1164 | async fn test_json_params_dont_decode() { 1165 | setup(); 1166 | let req = Request::builder() 1167 | .method(http::Method::POST) 1168 | .header(http::header::CONTENT_TYPE, "application/json") 1169 | .uri("/test") 1170 | .body(Body::new( 1171 | json!({ 1172 | "foo%3D1": "bar", 1173 | "baz": "qux%3D2" 1174 | }) 1175 | .to_string(), 1176 | )) 1177 | .unwrap(); 1178 | 1179 | let Params(params, _) = Params::::from_request(req, &()) 1180 | .await 1181 | .unwrap(); 1182 | assert_eq!(params.foo, None); 1183 | assert_eq!(params.baz, Some("qux%3D2".to_string())); 1184 | } 1185 | 1186 | #[tokio::test] 1187 | async fn test_encoded_form_params() { 1188 | setup(); 1189 | let req = Request::builder() 1190 | .method(http::Method::POST) 1191 | .header( 1192 | http::header::CONTENT_TYPE, 1193 | "application/x-www-form-urlencoded", 1194 | ) 1195 | .uri("/test") 1196 | .body(Body::new("foo%3D1=bar&baz=qux%3D2".to_string())) 1197 | .unwrap(); 1198 | 1199 | let Params(params, _) = Params::::from_request(req, &()) 1200 | .await 1201 | .unwrap(); 1202 | assert_eq!(params.foo, Some("bar".to_string())); 1203 | assert_eq!(params.baz, Some("qux=2".to_string())); 1204 | } 1205 | 1206 | #[derive(Debug, Deserialize, Serialize, PartialEq)] 1207 | pub struct OrderId(String); 1208 | 1209 | #[derive(Debug, Deserialize, Serialize, PartialEq)] 1210 | pub enum CurrencyCode { 1211 | #[serde(rename = "usd")] 1212 | Usd, 1213 | #[serde(rename = "gbp")] 1214 | Gbp, 1215 | #[serde(rename = "cad")] 1216 | Cad, 1217 | } 1218 | 1219 | #[derive(Debug, Deserialize, Serialize, PartialEq)] 1220 | struct PaymentRequest { 1221 | order_id: OrderId, 1222 | amount: f64, 1223 | currency: CurrencyCode, 1224 | description: Option, 1225 | } 1226 | 1227 | #[axum::debug_handler] 1228 | async fn payment_handler(Params(payment, _): Params) -> impl IntoResponse { 1229 | let response = json!({ 1230 | "order_id": payment.order_id.0, 1231 | "amount": payment.amount, 1232 | "currency": payment.currency, 1233 | "description": payment.description, 1234 | "processed": true 1235 | }); 1236 | 1237 | (StatusCode::OK, serde_json::to_string(&response).unwrap()) 1238 | } 1239 | 1240 | // Test for JSON body 1241 | #[tokio::test] 1242 | async fn test_currency_code_json() { 1243 | setup(); 1244 | 1245 | let json = r#"{ 1246 | "order_id": "1234567890", 1247 | "amount": 99.99, 1248 | "currency": "usd", 1249 | "description": "Test payment" 1250 | }"#; 1251 | 1252 | let req = Request::builder() 1253 | .method(http::Method::POST) 1254 | .header(http::header::CONTENT_TYPE, "application/json") 1255 | .body(Body::from(json)) 1256 | .unwrap(); 1257 | 1258 | let params = Params::::from_request(req, &()) 1259 | .await 1260 | .unwrap(); 1261 | assert_eq!(params.0.order_id.0, "1234567890"); 1262 | assert_eq!(params.0.amount, 99.99); 1263 | assert_eq!(params.0.currency, CurrencyCode::Usd); 1264 | assert_eq!(params.0.description, Some("Test payment".to_string())); 1265 | } 1266 | 1267 | // Test for request parameters (query params) 1268 | #[tokio::test] 1269 | async fn test_currency_code_query_params() { 1270 | setup(); 1271 | 1272 | let app = Router::new().route("/payment", get(payment_handler)); 1273 | let server = TestServer::new(app).unwrap(); 1274 | 1275 | let response = server 1276 | .get("/payment") 1277 | .add_query_params(&[ 1278 | ("order_id", "1234567890"), 1279 | ("amount", "199.99"), 1280 | ("currency", "gbp"), 1281 | ("description", "Query payment"), 1282 | ]) 1283 | .await; 1284 | 1285 | assert_eq!(response.status_code(), StatusCode::OK); 1286 | 1287 | let body: serde_json::Value = response.json(); 1288 | assert_eq!(body["order_id"], "1234567890"); 1289 | assert_eq!(body["amount"], 199.99); 1290 | assert_eq!(body["currency"], "gbp"); 1291 | assert_eq!(body["description"], "Query payment"); 1292 | assert_eq!(body["processed"], true); 1293 | } 1294 | 1295 | // Test for path parameters 1296 | #[tokio::test] 1297 | async fn test_currency_code_path_params() { 1298 | setup(); 1299 | 1300 | // Define a handler that extracts currency from path 1301 | async fn path_handler( 1302 | Path(currency): Path, 1303 | Params(payment, _): Params, 1304 | ) -> impl IntoResponse { 1305 | let response = json!({ 1306 | "order_id": payment.order_id.0, 1307 | "amount": payment.amount, 1308 | "currency": currency, 1309 | "description": payment.description, 1310 | "processed": true 1311 | }); 1312 | 1313 | (StatusCode::OK, serde_json::to_string(&response).unwrap()) 1314 | } 1315 | 1316 | let app = Router::new().route("/payment/{currency}", post(path_handler)); 1317 | let server = TestServer::new(app).unwrap(); 1318 | 1319 | let json_data = json!({ 1320 | "order_id": "1234567890", 1321 | "amount": 299.99, 1322 | "description": "Path payment" 1323 | }); 1324 | 1325 | let response = server.post("/payment/cad").json(&json_data).await; 1326 | 1327 | assert_eq!(response.status_code(), StatusCode::OK); 1328 | 1329 | let body: serde_json::Value = response.json(); 1330 | assert_eq!(body["order_id"], "1234567890"); 1331 | assert_eq!(body["amount"], 299.99); 1332 | assert_eq!(body["currency"], "cad"); 1333 | assert_eq!(body["description"], "Path payment"); 1334 | assert_eq!(body["processed"], true); 1335 | } 1336 | 1337 | // Test for form data 1338 | #[tokio::test] 1339 | async fn test_currency_code_form_data() { 1340 | setup(); 1341 | 1342 | let app = Router::new().route("/payment", post(payment_handler)); 1343 | let server = TestServer::new(app).unwrap(); 1344 | 1345 | let response = server 1346 | .post("/payment") 1347 | .form(&[ 1348 | ("order_id", "1234567890"), 1349 | ("amount", "399.99"), 1350 | ("currency", "gbp"), 1351 | ("description", "Form payment"), 1352 | ]) 1353 | .await; 1354 | 1355 | assert_eq!(response.status_code(), StatusCode::OK); 1356 | 1357 | let body: serde_json::Value = response.json(); 1358 | assert_eq!(body["order_id"], "1234567890"); 1359 | assert_eq!(body["amount"], 399.99); 1360 | assert_eq!(body["currency"], "gbp"); 1361 | assert_eq!(body["description"], "Form payment"); 1362 | assert_eq!(body["processed"], true); 1363 | } 1364 | } 1365 | --------------------------------------------------------------------------------