├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── app_route ├── Cargo.toml ├── README.md ├── benches │ └── benchmark.rs ├── src │ └── lib.rs └── tests │ └── derive_test.rs ├── app_route_derive ├── Cargo.toml ├── README.md └── src │ └── lib.rs └── rustfmt.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | app_route/target 3 | **/*.rs.bk 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "app_route", 5 | "app_route_derive", 6 | ] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Brian Schwind 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AppRoute 2 | ======= 3 | 4 | Treat application routes (URL path + query string) as strongly-typed Rust structs 5 | 6 | Dependencies 7 | ------------ 8 | - cargo 9 | - rustc 10 | 11 | Build 12 | ----- 13 | $ cargo build 14 | 15 | Test 16 | ----------- 17 | $ cargo test 18 | 19 | Benchmark 20 | ----------- 21 | $ cargo bench 22 | 23 | Example Code 24 | ------------ 25 | `src/Cargo.toml` 26 | ```toml 27 | [dependencies] 28 | app_route = "0.1" 29 | serde = { version = "1.0", features = ["derive"] } 30 | ``` 31 | 32 | `main.rs` 33 | ```rust 34 | use app_route::AppRoute; 35 | use serde::{Deserialize, Serialize}; 36 | 37 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 38 | struct UserListQuery { 39 | limit: Option, 40 | offset: Option, 41 | keyword: Option, 42 | 43 | #[serde(default)] 44 | friends_only: bool, 45 | } 46 | 47 | #[derive(AppRoute, Debug, PartialEq)] 48 | #[route("/groups/:group_id/users")] 49 | struct UsersListRoute { 50 | group_id: u64, 51 | 52 | #[query] 53 | query: UserListQuery, 54 | } 55 | 56 | fn main() { 57 | let path: UsersListRoute = 58 | "/groups/4313145/users?offset=10&limit=20&friends_only=false&keyword=some_keyword" 59 | .parse() 60 | .unwrap(); 61 | 62 | assert_eq!( 63 | path, 64 | UsersListRoute { 65 | group_id: 4313145, 66 | query: { 67 | UserListQuery { 68 | limit: Some(20), 69 | offset: Some(10), 70 | keyword: Some("some_keyword".to_string()), 71 | friends_only: false, 72 | } 73 | } 74 | } 75 | ); 76 | 77 | println!("Path: {}", path); 78 | // Output: 79 | // Path: /groups/4313145/users?limit=20&offset=10&keyword=some_keyword&friends_only=false 80 | } 81 | 82 | ``` 83 | 84 | TODO 85 | ---- 86 | 87 | - [ ] URL Hash Fragments 88 | - [x] Support trailing wildcard as a path param 89 | - [ ] Make the AppRoute trait object-safe if possible 90 | - [ ] Use spans properly in the procedural macro so errors actually make sense 91 | -------------------------------------------------------------------------------- /app_route/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app_route" 3 | description = "Treat application routes (URL path + query string) as strongly-typed Rust structs" 4 | license = "MIT" 5 | readme = "README.md" 6 | repository = "https://github.com/bschwind/app-route" 7 | version = "0.3.0" 8 | authors = ["Brian Schwind "] 9 | edition = "2018" 10 | 11 | [dependencies] 12 | app_route_derive = { version = "0.3.0", path = "../app_route_derive" } 13 | lazy_static = "1.3.0" 14 | regex = "1.1.6" 15 | serde_qs = "0.4.5" 16 | 17 | [dev-dependencies] 18 | serde = { version = "1.0", features = ["derive"] } 19 | criterion = "0.2" 20 | 21 | [[bench]] 22 | name = "benchmark" 23 | harness = false 24 | -------------------------------------------------------------------------------- /app_route/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /app_route/benches/benchmark.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate criterion; 3 | 4 | use app_route::AppRoute; 5 | use criterion::Criterion; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | // Trivial case 9 | #[derive(AppRoute, Debug, PartialEq)] 10 | #[route("/users")] 11 | struct UsersListPath {} 12 | 13 | fn trivial_benchmark(c: &mut Criterion) { 14 | c.bench_function("UsersListPath", |b| { 15 | b.iter(|| { 16 | let _path: UsersListPath = "/users".parse().unwrap(); 17 | }) 18 | }); 19 | } 20 | 21 | // Simple case 22 | #[derive(AppRoute, Debug, PartialEq)] 23 | #[route("/users/:user_id")] 24 | struct UserDetailPath { 25 | user_id: u64, 26 | } 27 | 28 | fn simple_benchmark(c: &mut Criterion) { 29 | c.bench_function("UserDetailPath", |b| { 30 | b.iter(|| { 31 | let _path: UserDetailPath = "/users/642151".parse().unwrap(); 32 | }) 33 | }); 34 | } 35 | 36 | // Nested case 37 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 38 | pub struct Building { 39 | name: String, 40 | number: Option, 41 | } 42 | 43 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 44 | #[serde(rename_all = "snake_case")] 45 | pub enum Country { 46 | CountryA, 47 | CountryB, 48 | CountryC, 49 | } 50 | 51 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 52 | pub struct Address { 53 | street_name: Option, 54 | apt_number: Option, 55 | country: Option, 56 | building: Option, 57 | } 58 | 59 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 60 | pub struct ParentQuery { 61 | address: Option
, 62 | } 63 | 64 | #[derive(AppRoute, Debug, PartialEq)] 65 | #[route("/users/:user_id")] 66 | struct UserDetailNestedQueryPath { 67 | user_id: u32, 68 | 69 | #[query] 70 | query: Option, 71 | } 72 | 73 | fn nested_benchmark(c: &mut Criterion) { 74 | c.bench_function("UserDetailNestedQueryPath", |b| b.iter(|| { 75 | let _path: UserDetailNestedQueryPath = "/users/1024?address[apt_number]=101&address[country]=country_b&address[building][name]=Cool%20Building&address[building][number]=9000".parse().unwrap(); 76 | })); 77 | } 78 | 79 | // Vector case 80 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 81 | pub struct VecQuery { 82 | friend_ids: Vec, 83 | } 84 | 85 | #[derive(AppRoute, Debug, PartialEq)] 86 | #[route("/users/:user_id")] 87 | struct UserDetailVecQueryPath { 88 | user_id: u32, 89 | 90 | #[query] 91 | query: Option, 92 | } 93 | 94 | fn vec_benchmark(c: &mut Criterion) { 95 | c.bench_function("UserDetailVecQueryPath", |b| { 96 | b.iter(|| { 97 | let _path: UserDetailVecQueryPath = 98 | "/users/1024?friend_ids[1]=20&friend_ids[2]=33&friend_ids[0]=1" 99 | .parse() 100 | .unwrap(); 101 | }) 102 | }); 103 | } 104 | 105 | criterion_group!( 106 | benches, 107 | trivial_benchmark, 108 | simple_benchmark, 109 | nested_benchmark, 110 | vec_benchmark 111 | ); 112 | criterion_main!(benches); 113 | -------------------------------------------------------------------------------- /app_route/src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | # Usage 3 | 4 | `src/Cargo.toml` 5 | ```toml 6 | [dependencies] 7 | app_route = "0.1" 8 | serde = { version = "1.0", features = ["derive"] } 9 | ``` 10 | 11 | `main.rs` 12 | ```rust 13 | use app_route::AppRoute; 14 | use serde::{Deserialize, Serialize}; 15 | 16 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 17 | struct UserListQuery { 18 | limit: Option, 19 | offset: Option, 20 | keyword: Option, 21 | 22 | #[serde(default)] 23 | friends_only: bool, 24 | } 25 | 26 | #[derive(AppRoute, Debug, PartialEq)] 27 | #[route("/groups/:group_id/users")] 28 | struct UsersListRoute { 29 | group_id: u64, 30 | 31 | #[query] 32 | query: UserListQuery, 33 | } 34 | 35 | fn main() { 36 | let path: UsersListRoute = 37 | "/groups/4313145/users?offset=10&limit=20&friends_only=false&keyword=some_keyword" 38 | .parse() 39 | .unwrap(); 40 | 41 | assert_eq!( 42 | path, 43 | UsersListRoute { 44 | group_id: 4313145, 45 | query: { 46 | UserListQuery { 47 | limit: Some(20), 48 | offset: Some(10), 49 | keyword: Some("some_keyword".to_string()), 50 | friends_only: false, 51 | } 52 | } 53 | } 54 | ); 55 | 56 | println!("Path: {}", path); 57 | // Output: 58 | // Path: /groups/4313145/users?limit=20&offset=10&keyword=some_keyword&friends_only=false 59 | } 60 | ``` 61 | */ 62 | 63 | #[doc(hidden)] 64 | pub use lazy_static::lazy_static; 65 | 66 | #[doc(hidden)] 67 | pub use regex::Regex; 68 | 69 | #[doc(hidden)] 70 | pub use serde_qs; 71 | 72 | pub use app_route_derive::AppRoute; 73 | 74 | #[derive(Debug)] 75 | pub enum RouteParseErr { 76 | NoMatches, 77 | NoQueryString, 78 | ParamParseErr(String), 79 | QueryParseErr(String), 80 | } 81 | 82 | impl std::fmt::Display for RouteParseErr { 83 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 84 | write!(f, "{:?}", self) 85 | } 86 | } 87 | 88 | pub trait AppRoute: std::fmt::Display + std::str::FromStr { 89 | fn path_pattern() -> String 90 | where 91 | Self: Sized; 92 | fn query_string(&self) -> Option; 93 | } 94 | -------------------------------------------------------------------------------- /app_route/tests/derive_test.rs: -------------------------------------------------------------------------------- 1 | use app_route::{AppRoute, RouteParseErr}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(AppRoute, Debug, PartialEq)] 5 | #[route("/users")] 6 | struct UsersListPath {} 7 | 8 | #[test] 9 | fn no_params() { 10 | let path: UsersListPath = "/users".parse().unwrap(); 11 | assert_eq!(path, UsersListPath {}); 12 | } 13 | 14 | #[test] 15 | fn trailing_slash() { 16 | let path: Result = "/users/".parse(); 17 | match path { 18 | Err(RouteParseErr::NoMatches) => {} 19 | _ => assert!(false), 20 | } 21 | } 22 | 23 | #[test] 24 | fn no_leading_slash() { 25 | let path: Result = "users".parse(); 26 | match path { 27 | Err(RouteParseErr::NoMatches) => {} 28 | _ => assert!(false), 29 | } 30 | } 31 | 32 | #[derive(AppRoute, Debug, PartialEq)] 33 | #[route("/users/:user_id")] 34 | struct UserDetailPath { 35 | user_id: u64, 36 | } 37 | 38 | #[test] 39 | fn one_param() { 40 | let path: UserDetailPath = "/users/642151".parse().unwrap(); 41 | assert_eq!(path, UserDetailPath { user_id: 642151 }); 42 | } 43 | 44 | #[test] 45 | fn invalid_param_type() { 46 | let path: Result = "/users/not_a_u64".parse(); 47 | match path { 48 | Err(RouteParseErr::ParamParseErr(_)) => {} 49 | _ => assert!(false), 50 | } 51 | } 52 | 53 | #[test] 54 | fn one_param_no_leading_slash() { 55 | let path: Result = "users/4216".parse(); 56 | match path { 57 | Err(RouteParseErr::NoMatches) => {} 58 | _ => assert!(false), 59 | } 60 | } 61 | 62 | #[derive(AppRoute, Debug, PartialEq)] 63 | #[route("/users/:user_id/friends/:friend_name")] 64 | struct UserFriendDetailPath { 65 | user_id: u64, 66 | friend_name: String, 67 | } 68 | 69 | #[test] 70 | fn two_params() { 71 | let path: UserFriendDetailPath = "/users/612451/friends/steve".parse().unwrap(); 72 | assert_eq!( 73 | path, 74 | UserFriendDetailPath { 75 | user_id: 612451, 76 | friend_name: "steve".to_string() 77 | } 78 | ); 79 | } 80 | 81 | #[test] 82 | fn two_params_utf8_1() { 83 | let path: UserFriendDetailPath = "/users/612451/friends/田中".parse().unwrap(); 84 | assert_eq!( 85 | path, 86 | UserFriendDetailPath { 87 | user_id: 612451, 88 | friend_name: "田中".to_string() 89 | } 90 | ); 91 | } 92 | 93 | #[test] 94 | fn two_params_utf8_2() { 95 | let path: UserFriendDetailPath = "/users/612451/friends/🌮🌮🌮".parse().unwrap(); 96 | assert_eq!( 97 | path, 98 | UserFriendDetailPath { 99 | user_id: 612451, 100 | friend_name: "🌮🌮🌮".to_string() 101 | } 102 | ); 103 | } 104 | 105 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 106 | struct UserListQuery { 107 | limit: Option, 108 | offset: Option, 109 | keyword: Option, 110 | 111 | #[serde(default)] 112 | friends_only: bool, 113 | } 114 | 115 | #[derive(AppRoute, Debug, PartialEq)] 116 | #[route("/users")] 117 | struct UsersListWithQuery { 118 | #[query] 119 | query: UserListQuery, 120 | } 121 | 122 | #[test] 123 | fn no_params_simple_query_required() { 124 | let path: Result = "/users".parse(); 125 | match path { 126 | Err(RouteParseErr::NoQueryString) => {} 127 | _ => assert!(false), 128 | } 129 | } 130 | 131 | #[test] 132 | fn no_params_simple_query() { 133 | let path: UsersListWithQuery = "/users?friends_only=true".parse().unwrap(); 134 | assert_eq!( 135 | path, 136 | UsersListWithQuery { 137 | query: { 138 | UserListQuery { 139 | limit: None, 140 | offset: None, 141 | keyword: None, 142 | friends_only: true, 143 | } 144 | } 145 | } 146 | ); 147 | } 148 | 149 | #[test] 150 | fn no_params_simple_query_missing_bool_field() { 151 | let path: UsersListWithQuery = "/users?".parse().unwrap(); 152 | assert_eq!( 153 | path, 154 | UsersListWithQuery { 155 | query: { 156 | UserListQuery { 157 | limit: None, 158 | offset: None, 159 | keyword: None, 160 | friends_only: false, 161 | } 162 | } 163 | } 164 | ); 165 | } 166 | 167 | #[test] 168 | fn no_params_simple_query_invalid_type() { 169 | let path: Result = "/users?offset=test".parse(); 170 | match path { 171 | Err(RouteParseErr::QueryParseErr(_)) => {} 172 | _ => assert!(false), 173 | } 174 | } 175 | 176 | #[test] 177 | fn no_params_simple_query_all_defined() { 178 | let path: UsersListWithQuery = 179 | "/users?offset=10&limit=20&friends_only=false&keyword=some_keyword" 180 | .parse() 181 | .unwrap(); 182 | assert_eq!( 183 | path, 184 | UsersListWithQuery { 185 | query: { 186 | UserListQuery { 187 | limit: Some(20), 188 | offset: Some(10), 189 | keyword: Some("some_keyword".to_string()), 190 | friends_only: false, 191 | } 192 | } 193 | } 194 | ); 195 | } 196 | 197 | #[test] 198 | fn no_params_simple_query_url_decoding() { 199 | let path: UsersListWithQuery = "/users?keyword=some%20keyword%20with%20ampersand-question-equals-stuff%26%3F%3d%3a%3b%40%23%25%5e%5b%5d%7b%7D%60%22%3c%3e%E6%97%A5%E6%9C%AC%E8%AA%9E".parse().unwrap(); 200 | assert_eq!( 201 | path, 202 | UsersListWithQuery { 203 | query: UserListQuery { 204 | limit: None, 205 | offset: None, 206 | keyword: Some( 207 | "some keyword with ampersand-question-equals-stuff&?=:;@#%^[]{}`\"<>日本語" 208 | .to_string() 209 | ), 210 | friends_only: false, 211 | } 212 | } 213 | ); 214 | } 215 | 216 | // TODO - uncomment after https://github.com/samscott89/serde_qs/pull/18 217 | // #[test] 218 | // fn no_params_simple_query_url_decoding_plus_sign() { 219 | // let path = UsersListWithQuery::from_str("/users?keyword=%2b").unwrap(); 220 | // assert_eq!(path, UsersListWithQuery { query: { 221 | // UserListQuery { 222 | // limit: None, 223 | // offset: None, 224 | // keyword: Some("+".to_string()), 225 | // friends_only: false, 226 | // } 227 | // } }); 228 | // } 229 | 230 | #[derive(AppRoute, Debug, PartialEq)] 231 | #[route("/users/:user_id")] 232 | struct UserDetailExtraPath { 233 | user_id: u8, 234 | 235 | #[query] 236 | query: Option, 237 | } 238 | 239 | #[test] 240 | fn one_param_optional_query_missing() { 241 | let path: UserDetailExtraPath = "/users/8".parse().unwrap(); 242 | assert_eq!( 243 | path, 244 | UserDetailExtraPath { 245 | user_id: 8, 246 | query: None 247 | } 248 | ); 249 | } 250 | 251 | #[test] 252 | fn one_param_optional_query_present() { 253 | let path: UserDetailExtraPath = "/users/8?limit=55".parse().unwrap(); 254 | assert_eq!( 255 | path, 256 | UserDetailExtraPath { 257 | user_id: 8, 258 | query: Some(UserListQuery { 259 | limit: Some(55), 260 | offset: None, 261 | keyword: None, 262 | friends_only: false, 263 | }) 264 | } 265 | ); 266 | } 267 | 268 | #[test] 269 | fn one_param_num_out_of_range() { 270 | let path: Result = "/users/256".parse(); 271 | match path { 272 | Err(RouteParseErr::ParamParseErr(_)) => {} 273 | _ => assert!(false), 274 | } 275 | } 276 | 277 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 278 | pub struct Building { 279 | name: String, 280 | number: Option, 281 | } 282 | 283 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 284 | #[serde(rename_all = "snake_case")] 285 | pub enum Country { 286 | CountryA, 287 | CountryB, 288 | CountryC, 289 | } 290 | 291 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 292 | pub struct Address { 293 | street_name: Option, 294 | apt_number: Option, 295 | country: Option, 296 | building: Option, 297 | } 298 | 299 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 300 | pub struct ParentQuery { 301 | // Not sure why you'd have an address here, but I had to think of 302 | // _something_ nested 303 | address: Option
, 304 | } 305 | 306 | #[derive(AppRoute, Debug, PartialEq)] 307 | #[route("/users/:user_id")] 308 | struct UserDetailNestedQueryPath { 309 | user_id: u32, 310 | 311 | #[query] 312 | query: Option, 313 | } 314 | 315 | #[test] 316 | fn nested_query_1() { 317 | let path: UserDetailNestedQueryPath = "/users/1024?address[apt_number]=101".parse().unwrap(); 318 | assert_eq!( 319 | path, 320 | UserDetailNestedQueryPath { 321 | user_id: 1024, 322 | query: Some(ParentQuery { 323 | address: Some(Address { 324 | street_name: None, 325 | apt_number: Some(101), 326 | country: None, 327 | building: None, 328 | }) 329 | }) 330 | } 331 | ); 332 | } 333 | 334 | #[test] 335 | fn nested_query_2() { 336 | let path: UserDetailNestedQueryPath = 337 | "/users/1024?address[apt_number]=101&address[country]=country_b" 338 | .parse() 339 | .unwrap(); 340 | assert_eq!( 341 | path, 342 | UserDetailNestedQueryPath { 343 | user_id: 1024, 344 | query: Some(ParentQuery { 345 | address: Some(Address { 346 | street_name: None, 347 | apt_number: Some(101), 348 | country: Some(Country::CountryB), 349 | building: None, 350 | }) 351 | }) 352 | } 353 | ); 354 | } 355 | 356 | #[test] 357 | fn nested_query_3() { 358 | let path: UserDetailNestedQueryPath = "/users/1024?address[apt_number]=101&address[country]=country_b&address[building][name]=Cool%20Building".parse().unwrap(); 359 | assert_eq!( 360 | path, 361 | UserDetailNestedQueryPath { 362 | user_id: 1024, 363 | query: Some(ParentQuery { 364 | address: Some(Address { 365 | street_name: None, 366 | apt_number: Some(101), 367 | country: Some(Country::CountryB), 368 | building: Some(Building { 369 | name: "Cool Building".to_string(), 370 | number: None, 371 | }) 372 | }) 373 | }) 374 | } 375 | ); 376 | } 377 | 378 | #[test] 379 | fn nested_query_4() { 380 | let path: UserDetailNestedQueryPath = "/users/1024?address[apt_number]=101&address[country]=country_b&address[building][name]=Cool%20Building&address[building][number]=not_number".parse().unwrap(); 381 | assert_eq!( 382 | path, 383 | UserDetailNestedQueryPath { 384 | user_id: 1024, 385 | query: None, 386 | } 387 | ); 388 | } 389 | 390 | #[test] 391 | fn nested_query_5() { 392 | let path: UserDetailNestedQueryPath = "/users/1024?address[apt_number]=101&address[country]=country_b&address[building][name]=Cool%20Building&address[building][number]=9000".parse().unwrap(); 393 | assert_eq!( 394 | path, 395 | UserDetailNestedQueryPath { 396 | user_id: 1024, 397 | query: Some(ParentQuery { 398 | address: Some(Address { 399 | street_name: None, 400 | apt_number: Some(101), 401 | country: Some(Country::CountryB), 402 | building: Some(Building { 403 | name: "Cool Building".to_string(), 404 | number: Some(9000), 405 | }) 406 | }) 407 | }) 408 | } 409 | ); 410 | } 411 | 412 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 413 | pub struct VecQuery { 414 | friend_ids: Vec, 415 | } 416 | 417 | #[derive(AppRoute, Debug, PartialEq)] 418 | #[route("/users/:user_id")] 419 | struct UserDetailVecQueryPath { 420 | user_id: u32, 421 | 422 | #[query] 423 | query: Option, 424 | } 425 | 426 | #[test] 427 | fn vec_query_1() { 428 | let path: UserDetailVecQueryPath = "/users/1024?".parse().unwrap(); 429 | assert_eq!( 430 | path, 431 | UserDetailVecQueryPath { 432 | user_id: 1024, 433 | query: None, 434 | } 435 | ); 436 | } 437 | 438 | #[test] 439 | fn vec_query_2() { 440 | let path: UserDetailVecQueryPath = "/users/1024?friend_ids".parse().unwrap(); 441 | assert_eq!( 442 | path, 443 | UserDetailVecQueryPath { 444 | user_id: 1024, 445 | query: None, 446 | } 447 | ); 448 | } 449 | 450 | #[test] 451 | fn vec_query_3() { 452 | let path: UserDetailVecQueryPath = "/users/1024?friend_ids[]=1".parse().unwrap(); 453 | assert_eq!( 454 | path, 455 | UserDetailVecQueryPath { 456 | user_id: 1024, 457 | query: Some(VecQuery { 458 | friend_ids: vec![1], 459 | }), 460 | } 461 | ); 462 | } 463 | 464 | #[test] 465 | fn vec_query_4() { 466 | let path: UserDetailVecQueryPath = "/users/1024?friend_ids[]=1&friend_ids[]=20" 467 | .parse() 468 | .unwrap(); 469 | assert_eq!( 470 | path, 471 | UserDetailVecQueryPath { 472 | user_id: 1024, 473 | query: Some(VecQuery { 474 | friend_ids: vec![1, 20], 475 | }), 476 | } 477 | ); 478 | } 479 | 480 | #[test] 481 | fn vec_query_5() { 482 | let path: UserDetailVecQueryPath = "/users/1024?friend_ids[]=1&friend_ids[]=20&friend_ids=33" 483 | .parse() 484 | .unwrap(); 485 | assert_eq!( 486 | path, 487 | UserDetailVecQueryPath { 488 | user_id: 1024, 489 | query: None, 490 | } 491 | ); 492 | } 493 | 494 | #[test] 495 | fn vec_query_6() { 496 | let path: UserDetailVecQueryPath = 497 | "/users/1024?friend_ids[0]=1&friend_ids[1]=20&friend_ids[2]=33" 498 | .parse() 499 | .unwrap(); 500 | assert_eq!( 501 | path, 502 | UserDetailVecQueryPath { 503 | user_id: 1024, 504 | query: Some(VecQuery { 505 | friend_ids: vec![1, 20, 33], 506 | }), 507 | } 508 | ); 509 | } 510 | 511 | #[test] 512 | fn vec_query_7() { 513 | let path: UserDetailVecQueryPath = 514 | "/users/1024?friend_ids[1]=20&friend_ids[2]=33&friend_ids[0]=1" 515 | .parse() 516 | .unwrap(); 517 | assert_eq!( 518 | path, 519 | UserDetailVecQueryPath { 520 | user_id: 1024, 521 | query: Some(VecQuery { 522 | friend_ids: vec![1, 20, 33], 523 | }), 524 | } 525 | ); 526 | } 527 | 528 | #[derive(Debug, PartialEq, Deserialize, Serialize)] 529 | #[serde(rename_all = "lowercase")] 530 | pub enum SortDirection { 531 | Asc, 532 | Desc, 533 | } 534 | 535 | #[derive(Debug, PartialEq, Deserialize, Serialize)] 536 | struct SubmissionsQuery { 537 | column: Option, 538 | direction: Option, 539 | keyword: Option, 540 | } 541 | 542 | #[derive(Debug, PartialEq, Deserialize, Serialize)] 543 | struct LimitOffsetQuery { 544 | limit: Option, 545 | offset: Option, 546 | } 547 | 548 | #[derive(AppRoute, Debug, PartialEq)] 549 | #[route("/p/:project_id/exams/:exam_id/submissions_expired")] 550 | struct ExpiredSubmissionsPath { 551 | project_id: String, 552 | exam_id: u64, 553 | 554 | #[query] 555 | query: std::option::Option, 556 | 557 | #[query] 558 | limit: Option, 559 | } 560 | 561 | #[test] 562 | fn test_no_query() { 563 | let path: ExpiredSubmissionsPath = "/p/43/exams/10/submissions_expired".parse().unwrap(); 564 | assert_eq!( 565 | path, 566 | ExpiredSubmissionsPath { 567 | project_id: "43".to_string(), 568 | exam_id: 10, 569 | query: None, 570 | limit: None, 571 | } 572 | ); 573 | } 574 | 575 | #[test] 576 | fn test_only_question_mark() { 577 | let path: ExpiredSubmissionsPath = "/p/43/exams/10/submissions_expired?".parse().unwrap(); 578 | assert_eq!( 579 | path, 580 | ExpiredSubmissionsPath { 581 | project_id: "43".to_string(), 582 | exam_id: 10, 583 | query: Some(SubmissionsQuery { 584 | column: None, 585 | direction: None, 586 | keyword: None, 587 | }), 588 | limit: Some(LimitOffsetQuery { 589 | limit: None, 590 | offset: None, 591 | }), 592 | } 593 | ); 594 | } 595 | 596 | #[derive(AppRoute, Debug, PartialEq)] 597 | #[route("/users:tail*")] 598 | struct UsersWildcardRoute { 599 | tail: String, 600 | } 601 | 602 | #[test] 603 | fn wildcard_1() { 604 | let path: UsersWildcardRoute = "/users".parse().unwrap(); 605 | assert_eq!( 606 | path, 607 | UsersWildcardRoute { 608 | tail: "".to_string(), 609 | } 610 | ); 611 | } 612 | 613 | #[test] 614 | fn wildcard_2() { 615 | let path: UsersWildcardRoute = "/users/slurmp".parse().unwrap(); 616 | assert_eq!( 617 | path, 618 | UsersWildcardRoute { 619 | tail: "/slurmp".to_string(), 620 | } 621 | ); 622 | } 623 | 624 | #[test] 625 | fn wildcard_3() { 626 | let path: UsersWildcardRoute = 627 | "/users/slurmp/social_accounts/twitter?some_query_string=1#whatever_hash" 628 | .parse() 629 | .unwrap(); 630 | assert_eq!( 631 | path, 632 | UsersWildcardRoute { 633 | tail: "/slurmp/social_accounts/twitter".to_string(), 634 | } 635 | ); 636 | } 637 | 638 | #[derive(AppRoute, Debug, PartialEq)] 639 | #[route("/users/:tail*")] 640 | struct UsersWildcardTrailingSlashRoute { 641 | tail: String, 642 | } 643 | 644 | #[test] 645 | fn wildcard_5() { 646 | // The route pattern has an explicit '/' after 'users', so a path 647 | // such as '/users' will not match this pattern 648 | let path: Result = "/users".parse(); 649 | match path { 650 | Err(RouteParseErr::NoMatches) => {} 651 | _ => assert!(false), 652 | } 653 | } 654 | 655 | #[test] 656 | fn wildcard_6() { 657 | let path: UsersWildcardTrailingSlashRoute = 658 | "/users/slurmp/social_accounts/twitter?some_query_string=1#whatever_hash" 659 | .parse() 660 | .unwrap(); 661 | assert_eq!( 662 | path, 663 | UsersWildcardTrailingSlashRoute { 664 | tail: "slurmp/social_accounts/twitter".to_string(), 665 | } 666 | ); 667 | } 668 | 669 | #[derive(AppRoute, Debug, PartialEq)] 670 | #[route("/:friend_name/social_accounts/:social_name")] 671 | struct FriendSocialRoute { 672 | friend_name: String, 673 | social_name: String, 674 | } 675 | 676 | // Perhaps a bit too wild 677 | #[derive(AppRoute, Debug, PartialEq)] 678 | #[route("/users:tail*")] 679 | struct NestedFancyRoute { 680 | tail: FriendSocialRoute, 681 | } 682 | 683 | #[test] 684 | fn wildcard_7() { 685 | let path: NestedFancyRoute = 686 | "/users/slurmp/social_accounts/twitter?some_query_string=1#whatever_hash" 687 | .parse() 688 | .unwrap(); 689 | 690 | assert_eq!( 691 | path, 692 | NestedFancyRoute { 693 | tail: FriendSocialRoute { 694 | friend_name: "slurmp".to_string(), 695 | social_name: "twitter".to_string(), 696 | }, 697 | } 698 | ); 699 | } 700 | -------------------------------------------------------------------------------- /app_route_derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app_route_derive" 3 | description = "A procedural macro to allow automatic deserialization of URL path + query parameters into Rust structs" 4 | license = "MIT" 5 | readme = "README.md" 6 | repository = "https://github.com/bschwind/app-route" 7 | version = "0.3.0" 8 | authors = ["Brian Schwind "] 9 | edition = "2018" 10 | 11 | [lib] 12 | proc-macro = true 13 | 14 | [dependencies] 15 | syn = { version = "0.15", features = ["extra-traits"] } 16 | proc-macro2 = "0.4.28" 17 | quote = "0.6.12" 18 | regex = "1.1.6" 19 | -------------------------------------------------------------------------------- /app_route_derive/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /app_route_derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | extern crate proc_macro; 4 | use proc_macro::TokenStream; 5 | use proc_macro2; 6 | use quote::quote; 7 | use regex::Regex; 8 | use std::collections::HashSet; 9 | use syn::{parse_macro_input, DeriveInput}; 10 | 11 | #[derive(Debug, PartialEq)] 12 | enum RouteToRegexError { 13 | MissingLeadingForwardSlash, 14 | NonAsciiChars, 15 | InvalidIdentifier(String), 16 | InvalidTrailingSlash, 17 | CharactersAfterWildcard, 18 | } 19 | 20 | fn route_to_regex(route: &str) -> Result<(String, String), RouteToRegexError> { 21 | enum ParseState { 22 | Initial, 23 | Static, 24 | VarName(String), 25 | WildcardFound, 26 | }; 27 | 28 | if !route.is_ascii() { 29 | return Err(RouteToRegexError::NonAsciiChars); 30 | } 31 | 32 | let ident_regex = Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$").unwrap(); 33 | 34 | let mut regex = "".to_string(); 35 | let mut format_str = "".to_string(); 36 | let mut parse_state = ParseState::Initial; 37 | 38 | for byte in route.chars() { 39 | match parse_state { 40 | ParseState::Initial => { 41 | if byte != '/' { 42 | return Err(RouteToRegexError::MissingLeadingForwardSlash); 43 | } 44 | 45 | regex += "^/"; 46 | format_str += "/"; 47 | 48 | parse_state = ParseState::Static; 49 | } 50 | ParseState::Static => { 51 | if byte == ':' { 52 | format_str.push('{'); 53 | parse_state = ParseState::VarName("".to_string()); 54 | } else { 55 | regex.push(byte); 56 | format_str.push(byte); 57 | parse_state = ParseState::Static; 58 | } 59 | } 60 | ParseState::VarName(mut name) => { 61 | if byte == '/' { 62 | // Validate 'name' as a Rust identifier 63 | if !ident_regex.is_match(&name) { 64 | return Err(RouteToRegexError::InvalidIdentifier(name)); 65 | } 66 | 67 | regex += &format!("(?P<{}>[^/]+)/", name); 68 | format_str += &format!("{}}}/", name); 69 | parse_state = ParseState::Static; 70 | } else if byte == '*' { 71 | // Found a wildcard - add the var name to the regex 72 | 73 | // Validate 'name' as a Rust identifier 74 | if !ident_regex.is_match(&name) { 75 | return Err(RouteToRegexError::InvalidIdentifier(name)); 76 | } 77 | 78 | regex += &format!("(?P<{}>.*)", name); 79 | format_str += &format!("{}}}", name); 80 | parse_state = ParseState::WildcardFound; 81 | } else { 82 | name.push(byte); 83 | parse_state = ParseState::VarName(name); 84 | } 85 | } 86 | ParseState::WildcardFound => { 87 | return Err(RouteToRegexError::CharactersAfterWildcard); 88 | } 89 | }; 90 | } 91 | 92 | if let ParseState::VarName(name) = parse_state { 93 | regex += &format!("(?P<{}>[^/]+)", name); 94 | format_str += &format!("{}}}", name); 95 | } 96 | 97 | if regex.ends_with('/') { 98 | return Err(RouteToRegexError::InvalidTrailingSlash); 99 | } 100 | 101 | regex += "$"; 102 | 103 | Ok((regex, format_str)) 104 | } 105 | 106 | #[test] 107 | fn test_route_to_regex() { 108 | let (regex, _) = route_to_regex("/p/:project_id/exams/:exam_id/submissions_expired").unwrap(); 109 | assert_eq!( 110 | regex, 111 | r"^/p/(?P[^/]+)/exams/(?P[^/]+)/submissions_expired$" 112 | ); 113 | } 114 | 115 | #[test] 116 | fn test_route_to_regex_no_path_params() { 117 | let (regex, _) = route_to_regex("/p/exams/submissions_expired").unwrap(); 118 | assert_eq!(regex, r"^/p/exams/submissions_expired$"); 119 | } 120 | 121 | #[test] 122 | fn test_route_to_regex_no_leading_slash() { 123 | let regex = route_to_regex("p/exams/submissions_expired"); 124 | assert_eq!(regex, Err(RouteToRegexError::MissingLeadingForwardSlash)); 125 | } 126 | 127 | #[test] 128 | fn test_route_to_regex_non_ascii_chars() { 129 | let regex = route_to_regex("🥖p🥖:project_id🥖exams🥖:exam_id🥖submissions_expired"); 130 | assert_eq!(regex, Err(RouteToRegexError::NonAsciiChars)); 131 | } 132 | 133 | #[test] 134 | fn test_route_to_regex_invalid_ident() { 135 | let regex = route_to_regex("/p/:project_id/exams/:_exam_id/submissions_expired"); 136 | assert_eq!( 137 | regex, 138 | Err(RouteToRegexError::InvalidIdentifier("_exam_id".to_string())) 139 | ); 140 | } 141 | 142 | #[test] 143 | fn test_route_to_regex_characters_after_wildcard() { 144 | let regex = route_to_regex("/p/:project_id/exams/:exam*ID/submissions_expired"); 145 | assert_eq!( 146 | regex, 147 | Err(RouteToRegexError::CharactersAfterWildcard) 148 | ); 149 | } 150 | 151 | #[test] 152 | fn test_route_to_regex_invalid_ending() { 153 | let regex = route_to_regex("/p/:project_id/exams/:exam_id/submissions_expired/"); 154 | assert_eq!(regex, Err(RouteToRegexError::InvalidTrailingSlash)); 155 | } 156 | 157 | fn get_string_attr(name: &str, attrs: &[syn::Attribute]) -> Option { 158 | for attr in attrs { 159 | let attr = attr.parse_meta(); 160 | 161 | if let Ok(syn::Meta::List(ref list)) = attr { 162 | if list.ident == name { 163 | for thing in &list.nested { 164 | if let syn::NestedMeta::Literal(syn::Lit::Str(str_lit)) = thing { 165 | return Some(str_lit.value()); 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | None 173 | } 174 | 175 | fn has_flag_attr(name: &str, attrs: &[syn::Attribute]) -> bool { 176 | for attr in attrs { 177 | let attr = attr.parse_meta(); 178 | 179 | if let Ok(syn::Meta::Word(ref ident)) = attr { 180 | if ident == name { 181 | return true; 182 | } 183 | } 184 | } 185 | 186 | false 187 | } 188 | 189 | fn get_struct_fields(data: &syn::Data) -> Vec { 190 | match data { 191 | syn::Data::Struct(data_struct) => match data_struct.fields { 192 | syn::Fields::Named(ref named_fields) => named_fields.named.iter().cloned().collect(), 193 | _ => panic!("Struct fields must be named"), 194 | }, 195 | _ => panic!("AppRoute derive is only supported for structs"), 196 | } 197 | } 198 | 199 | fn field_is_option(field: &syn::Field) -> bool { 200 | match field.ty { 201 | syn::Type::Path(ref type_path) => type_path 202 | .path 203 | .segments 204 | .iter() 205 | .last() 206 | .map(|segment| segment.ident == "Option") 207 | .unwrap_or(false), 208 | _ => false, 209 | } 210 | } 211 | 212 | #[proc_macro_derive(AppRoute, attributes(route, query))] 213 | pub fn app_route_derive(input: TokenStream) -> TokenStream { 214 | let input = parse_macro_input!(input as DeriveInput); 215 | 216 | let struct_fields = get_struct_fields(&input.data); 217 | 218 | let (route_fields, query_fields): (Vec<_>, Vec<_>) = struct_fields 219 | .into_iter() 220 | .partition(|f| !has_flag_attr("query", &f.attrs)); 221 | 222 | let name = &input.ident; 223 | let generics = input.generics; 224 | let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); 225 | 226 | let route_string = get_string_attr("route", &input.attrs); 227 | 228 | let url_route = route_string.expect( 229 | "derive(AppRoute) requires a #[route(\"/your/route/here\")] attribute on the struct", 230 | ); 231 | 232 | let (route_regex_str, format_str) = 233 | route_to_regex(&url_route).expect("Could not convert route attribute to a valid regex"); 234 | 235 | // Validate route_regex and make sure struct and route have matching fields 236 | let route_regex = 237 | Regex::new(&route_regex_str).expect("route attribute was not compiled into a valid regex"); 238 | 239 | let regex_capture_names_set: HashSet = route_regex 240 | .capture_names() 241 | .filter_map(|c_opt| c_opt.map(|c| c.to_string())) 242 | .collect(); 243 | let field_names_set: HashSet = route_fields 244 | .clone() 245 | .into_iter() 246 | .map(|f| f.ident.unwrap().to_string()) 247 | .collect(); 248 | 249 | if regex_capture_names_set != field_names_set { 250 | let missing_from_route = field_names_set.difference(®ex_capture_names_set); 251 | let missing_from_struct = regex_capture_names_set.difference(&field_names_set); 252 | 253 | let error_msg = format!("\nFields in struct missing from route pattern: {:?}\nFields in route missing from struct: {:?}", missing_from_route, missing_from_struct); 254 | panic!(error_msg); 255 | } 256 | 257 | let route_field_assignments = route_fields.clone().into_iter().map(|f| { 258 | let f_ident = f.ident.unwrap(); 259 | let f_ident_str = f_ident.to_string(); 260 | 261 | quote! { 262 | #f_ident: captures[#f_ident_str].parse().map_err(|e| { 263 | RouteParseErr::ParamParseErr(std::string::ToString::to_string(&e)) 264 | })? 265 | } 266 | }); 267 | 268 | let query_field_assignments = query_fields.clone().into_iter().map(|f| { 269 | let is_option = field_is_option(&f); 270 | let f_ident = f.ident.unwrap(); 271 | 272 | if is_option { 273 | quote! { 274 | #f_ident: query_string.and_then(|q| qs::from_str(q).ok()) 275 | } 276 | } else { 277 | quote! { 278 | #f_ident: qs::from_str(query_string.ok_or(RouteParseErr::NoQueryString)?).map_err(|e| RouteParseErr::QueryParseErr(e.description().to_string()))? 279 | } 280 | } 281 | }); 282 | 283 | let route_field_parsers = quote! { 284 | #( 285 | #route_field_assignments 286 | ),* 287 | }; 288 | 289 | let query_field_parsers = quote! { 290 | #( 291 | #query_field_assignments 292 | ),* 293 | }; 294 | 295 | let format_args = route_fields.clone().into_iter().map(|f| { 296 | let f_ident = f.ident.unwrap(); 297 | 298 | quote! { 299 | #f_ident = self.#f_ident 300 | } 301 | }); 302 | 303 | let format_args = quote! { 304 | #( 305 | #format_args 306 | ),* 307 | }; 308 | 309 | let query_field_to_string_statements = query_fields.into_iter().map(|f| { 310 | let is_option = field_is_option(&f); 311 | let f_ident = f.ident.unwrap(); 312 | 313 | if is_option { 314 | quote! { 315 | self.#f_ident.as_ref().and_then(|q| qs::to_string(&q).ok()) 316 | } 317 | } else { 318 | quote! { 319 | qs::to_string(&self.#f_ident).ok() 320 | } 321 | } 322 | }); 323 | 324 | let encoded_query_fields = quote! { 325 | #( 326 | #query_field_to_string_statements 327 | ),* 328 | }; 329 | 330 | let struct_constructor = match ( 331 | route_field_parsers.is_empty(), 332 | query_field_parsers.is_empty(), 333 | ) { 334 | (true, true) => quote! { 335 | #name {} 336 | }, 337 | (true, false) => quote! { 338 | #name { 339 | #query_field_parsers 340 | } 341 | }, 342 | (false, true) => quote! { 343 | #name { 344 | #route_field_parsers 345 | } 346 | }, 347 | (false, false) => quote! { 348 | #name { 349 | #route_field_parsers, 350 | #query_field_parsers 351 | } 352 | }, 353 | }; 354 | 355 | let app_route_impl = quote! { 356 | impl #impl_generics app_route::AppRoute for #name #ty_generics #where_clause { 357 | 358 | fn path_pattern() -> String { 359 | #route_regex_str.to_string() 360 | } 361 | 362 | fn query_string(&self) -> Option { 363 | use app_route::serde_qs as qs; 364 | 365 | // TODO - Remove duplicates because 366 | // there could be multiple fields with 367 | // a #[query] attribute that have common fields 368 | 369 | // TODO - can this be done with an on-stack array? 370 | let encoded_queries: Vec> = vec![#encoded_query_fields]; 371 | let filtered: Vec<_> = encoded_queries.into_iter().filter_map(std::convert::identity).collect(); 372 | 373 | if !filtered.is_empty() { 374 | Some(filtered.join("&")) 375 | } else { 376 | None 377 | } 378 | } 379 | } 380 | 381 | impl #impl_generics std::fmt::Display for #name #ty_generics #where_clause { 382 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 383 | if let Some(query) = self.query_string() { 384 | let path = format!( 385 | #format_str, 386 | #format_args 387 | ); 388 | 389 | write!(f, "{}?{}", path, query) 390 | } else { 391 | write!( 392 | f, 393 | #format_str, 394 | #format_args 395 | ) 396 | } 397 | } 398 | } 399 | 400 | impl #impl_generics std::str::FromStr for #name #ty_generics #where_clause { 401 | type Err = app_route::RouteParseErr; 402 | 403 | fn from_str(app_path: &str) -> Result { 404 | use app_route::serde_qs as qs; 405 | use app_route::RouteParseErr; 406 | 407 | app_route::lazy_static! { 408 | static ref ROUTE_REGEX: app_route::Regex = app_route::Regex::new(#route_regex_str).expect("Failed to compile regex"); 409 | } 410 | 411 | let question_pos = app_path.find('?'); 412 | let just_path = &app_path[..(question_pos.unwrap_or_else(|| app_path.len()))]; 413 | 414 | let captures = (*ROUTE_REGEX).captures(just_path).ok_or(RouteParseErr::NoMatches)?; 415 | 416 | let query_string = question_pos.map(|question_pos| { 417 | let mut query_string = &app_path[question_pos..]; 418 | 419 | if query_string.starts_with('?') { 420 | query_string = &query_string[1..]; 421 | } 422 | 423 | query_string 424 | }); 425 | 426 | Ok(#struct_constructor) 427 | } 428 | } 429 | }; 430 | 431 | let impl_wrapper = syn::Ident::new( 432 | &format!("_IMPL_APPROUTE_FOR_{}", name.to_string()), 433 | proc_macro2::Span::call_site(), 434 | ); 435 | 436 | let out = quote! { 437 | const #impl_wrapper: () = { 438 | extern crate app_route; 439 | #app_route_impl 440 | }; 441 | }; 442 | 443 | out.into() 444 | } 445 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | use_field_init_shorthand = true 3 | edition = "2018" 4 | 5 | # imports_layout = "Vertical" # Currently unstable 6 | # merge_imports = true # Currently unstable 7 | --------------------------------------------------------------------------------