diff --git a/Cargo.lock b/Cargo.lock index 59c3c4b..0b640bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,199 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "actix-bench-server" -version = "0.1.0" -dependencies = [ - "actix-web", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-http" -version = "3.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "base64 0.22.1", - "bitflags 2.10.0", - "brotli 8.0.2", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "foldhash 0.1.5", - "futures-core", - "h2 0.3.27", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.9.2", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.111", -] - -[[package]] -name = "actix-router" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.10", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "actix-web-codegen", - "bytes", - "bytestring", - "cfg-if", - "cookie 0.16.2", - "derive_more", - "encoding_rs", - "foldhash 0.1.5", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.6.1", - "time", - "tracing", - "url", -] - -[[package]] -name = "actix-web-codegen" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "adler2" version = "2.0.1" @@ -252,12 +59,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" version = "0.6.21" @@ -474,18 +275,6 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" -[[package]] -name = "bench-server" -version = "0.1.207" -dependencies = [ - "rustapi-rs", - "serde", - "serde_json", - "tokio", - "utoipa", - "validator", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -596,15 +385,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" -[[package]] -name = "bytestring" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" -dependencies = [ - "bytes", -] - [[package]] name = "cargo-rustapi" version = "0.1.207" @@ -629,12 +409,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" version = "1.2.51" @@ -701,33 +475,6 @@ dependencies = [ "phf_codegen", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "clap" version = "4.5.53" @@ -836,26 +583,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "cookie" version = "0.18.1" @@ -925,42 +652,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1004,12 +695,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1130,29 +815,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.111", - "unicode-xid", -] - [[package]] name = "deunicode" version = "1.6.2" @@ -1687,17 +1349,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "halfbrown" version = "0.2.5" @@ -1769,12 +1420,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -2167,12 +1812,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - [[package]] name = "indexmap" version = "1.9.3" @@ -2233,32 +1872,12 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.11.0" @@ -2331,12 +1950,6 @@ dependencies = [ "simple_asn1", ] -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - [[package]] name = "lazy_static" version = "1.5.0" @@ -2412,23 +2025,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - [[package]] name = "lock_api" version = "0.4.14" @@ -2514,7 +2110,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -2645,12 +2240,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "openssl" version = "0.10.75" @@ -3015,34 +2604,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "portable-atomic" version = "1.13.0" @@ -3195,7 +2756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools", "proc-macro2", "quote", "syn 2.0.111", @@ -3365,26 +2926,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "rcgen" version = "0.13.2" @@ -3480,12 +3021,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" - [[package]] name = "regex-syntax" version = "0.8.8" @@ -3613,7 +3148,7 @@ dependencies = [ "arc-swap", "base62", "globwalk 0.8.1", - "itertools 0.11.0", + "itertools", "lazy_static", "normpath", "once_cell", @@ -3627,16 +3162,6 @@ dependencies = [ "triomphe", ] -[[package]] -name = "rustapi-bench" -version = "0.1.207" -dependencies = [ - "criterion", - "serde", - "serde_json", - "serde_urlencoded", -] - [[package]] name = "rustapi-core" version = "0.1.207" @@ -3646,7 +3171,7 @@ dependencies = [ "brotli 6.0.0", "bytes", "chrono", - "cookie 0.18.1", + "cookie", "flate2", "futures-util", "h3", @@ -3694,7 +3219,7 @@ version = "0.1.207" dependencies = [ "base64 0.22.1", "bytes", - "cookie 0.18.1", + "cookie", "dashmap", "diesel", "dotenvy", @@ -3763,7 +3288,7 @@ dependencies = [ "http-body-util", "serde", "serde_json", - "utoipa", + "uuid", ] [[package]] @@ -3894,15 +3419,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.3" @@ -4844,16 +4360,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.10.0" @@ -5031,16 +4537,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "toon-bench" -version = "0.1.207" -dependencies = [ - "criterion", - "serde", - "serde_json", - "toon-format", -] - [[package]] name = "toon-format" version = "0.4.1" @@ -5326,12 +4822,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 9850e72..e5aff31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,11 @@ members = [ "crates/rustapi-jobs", "crates/cargo-rustapi", - # Benchmark servers - "benches/bench_server", - "benches/actix_bench_server", - "benches/toon_bench", - "benches/rustapi_bench", + # Benchmark servers - https://github.com/Tuntii/RustAPI-benchmark + # "benches/bench_server", + # "benches/actix_bench_server", + # "benches/toon_bench", + # "benches/rustapi_bench", ] [workspace.package] diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..acdefea --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,66 @@ +# RustAPI 0.2.0 Release Notes + +**"Zero-Config & Native Attributes"** + +This release marks a significant milestone in RustAPI's ergonomics, introducing a fully declarative "Code-First" approach to building APIs. It bridges the gap between Rust's performance and the ease of use found in frameworks like FastAPI. + +## 🚀 Key Features + +### 1. Zero-Config Routing (`RustApi::auto()`) +Gone are the days of manually mounting every single route in your `main` function. With the new auto-discovery mechanism, RustAPI scans your code for decorated handlers and registers them automatically. + +**Before:** +```rust +// main.rs +let app = RustApi::new() + .route("/users", get(list_users)) + .route("/users", post(create_user)) + .route("/users/{id}", get(get_user)); +``` + +**After:** +```rust +// handlers.rs +#[rustapi::get("/users")] +async fn list_users() { ... } + +// main.rs +let app = RustApi::auto(); // That's it! +``` + +### 2. Native OpenAPI Attributes +Define your API structure and documentation right where your code lives. The new attribute macros allow you to control every aspect of the OpenAPI spec without leaving your handler function. + +```rust +#[rustapi::get("/items/{id}")] +#[rustapi::tag("Inventory")] +#[rustapi::summary("Find item by ID")] +#[rustapi::response(404, description = "Item not found")] +async fn get_item(Path(id): Path) -> Result> { ... } +``` + +### 3. Smart Parameter Inference +RustAPI now intelligently guesses the OpenAPI data types for your path parameters based on their names, reducing the need for manual annotation. + +* `id`, `user_id` -> Inferred as `integer (int64)` +* `uuid`, `transaction_uuid` -> Inferred as `string (uuid)` +* Others -> Inferred as `string` + +You can still override this manually if needed: +```rust +#[rustapi::param(custom_id, schema = "string")] +``` + +## 🛠️ Improvements & Fixes + +* **Cookbook Update**: Added a comprehensive "Zero-Config OpenAPI" recipe to the documentation. +* **Clippy Fixes**: Resolved `clippy::to_string_trait_impl` warnings in `rustapi-openapi` for cleaner compilations. +* **Example Updates**: `openapi_demo` example updated to showcase the new declarative style. +* **Error Handling**: Fixed `unused_must_use` warnings in examples by properly propagating `Result` in `main`. + +## 📦 Migration Guide + +This release is backwards compatible. Your existing manual `.route()` calls will continue to work. To adopt the new features: +1. Add `#[rustapi::get/post/...]` attributes to your handler functions. +2. Switch from `RustApi::new()` to `RustApi::auto()` in your entry point. +3. Ensure your data structs derive `ToSchema` (for bodies) and `IntoParams` (for query strings). diff --git a/benches/README.md b/benches/README.md deleted file mode 100644 index 6549a37..0000000 --- a/benches/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# RustAPI Benchmarks - -Bu klasör, RustAPI framework'ünün performans testlerini içerir. - -## 🎯 Benchmark Türleri - -### 1. Micro-benchmarks (Criterion.rs) -Framework'ün iç bileşenlerini test eder: -- **Routing**: URL eşleştirme hızı -- **JSON Serialization**: Serialize/deserialize performansı -- **Extractors**: Path, Query, Json extractor'ların hızı - -### 2. HTTP Load Testing -Gerçek HTTP istekleriyle end-to-end performans: -- **Hello World**: Basit text yanıt -- **JSON Response**: JSON serialize edilmiş yanıt -- **Path Parameters**: Dynamic route parametreleri -- **JSON Parsing**: Request body parsing - -## 🚀 Benchmark Çalıştırma - -### Micro-benchmarks -```bash -cargo bench -``` - -### HTTP Load Tests (Automated Script) -```powershell -# Run the automated benchmark script -.\benches\run_benchmarks.ps1 -``` - -## 📈 RustAPI vs Actix-web Comparison - -| Framework | Hello World | JSON Response | Path Params | POST JSON | -|-----------|-------------|---------------|-------------|-----------| -| RustAPI | ~4,000 req/s| ~4,200 req/s | ~4,000 req/s| ~5,400 req/s| -| Actix-web | ~39,000 req/s| ~31,000 req/s | ~36,000 req/s| ~33,000 req/s| - -> Note: Benchmarks depend on system environment. These results were taken on a developer machine with 1000 requests and 5 concurrency. - -## 🔥 Neden RustAPI? - -RustAPI, Actix-web ile karşılaştırıldığında: - -### ✅ Avantajlar -1. **Developer Experience (DX)**: FastAPI benzeri ergonomi -2. **Automatic OpenAPI**: Kod yazdıkça dökümantasyon otomatik oluşur -3. **Built-in Validation**: `#[validate]` macro'ları ile otomatik 422 hatası -4. **Simpler API**: Daha az boilerplate, daha okunabilir kod -5. **Hyper 1.0**: Modern ve stabil HTTP stack - -### 📊 Performans -- RustAPI ham hızda Actix-web'e yakın performans sunar (%90-95) -- Gerçek dünya uygulamalarında bu fark göz ardı edilebilir -- DX kazanımları, küçük performans farkından daha değerli - -### 🎯 Ne Zaman RustAPI Kullanmalı? -- API-first projeler -- OpenAPI/Swagger dökümantasyonu gereken projeler -- Hızlı prototipleme -- JSON-ağırlıklı REST API'lar - -### 🎯 Ne Zaman Actix-web Kullanmalı? -- Maksimum raw performans kritik -- WebSocket ağırlıklı uygulamalar -- Olgun ekosistem gereken büyük projeler diff --git a/benches/actix_bench_server/Cargo.toml b/benches/actix_bench_server/Cargo.toml deleted file mode 100644 index 0e7250f..0000000 --- a/benches/actix_bench_server/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "actix-bench-server" -version = "0.1.0" -edition = "2021" -publish = false - -[[bin]] -name = "actix-bench-server" -path = "src/main.rs" - -[dependencies] -actix-web = "4" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tokio = { version = "1", features = ["full"] } diff --git a/benches/actix_bench_server/src/main.rs b/benches/actix_bench_server/src/main.rs deleted file mode 100644 index 9f5595f..0000000 --- a/benches/actix_bench_server/src/main.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Actix-web benchmark server for comparison -//! -//! Run with: cargo run --release -p actix-bench-server -//! Then test with: hey -n 100000 -c 50 http://127.0.0.1:8081/ - -use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; -use serde::{Deserialize, Serialize}; - -// ============================================ -// Response types (same as RustAPI) -// ============================================ - -#[derive(Serialize)] -struct HelloResponse { - message: String, -} - -#[derive(Serialize)] -struct UserResponse { - id: i64, - name: String, - email: String, - created_at: String, - is_active: bool, -} - -#[derive(Serialize)] -struct UsersListResponse { - users: Vec, - total: usize, - page: usize, -} - -#[derive(Serialize)] -struct PostResponse { - user_id: i64, - post_id: i64, - title: String, - content: String, -} - -#[derive(Deserialize)] -struct CreateUser { - name: String, - email: String, -} - -// ============================================ -// Handlers -// ============================================ - -#[get("/")] -async fn hello() -> impl Responder { - "Hello, World!" -} - -#[get("/json")] -async fn json_hello() -> impl Responder { - HttpResponse::Ok().json(HelloResponse { - message: "Hello, World!".to_string(), - }) -} - -#[get("/users/{id}")] -async fn get_user(path: web::Path) -> impl Responder { - let id = path.into_inner(); - HttpResponse::Ok().json(UserResponse { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - created_at: "2024-01-01T00:00:00Z".to_string(), - is_active: true, - }) -} - -#[get("/users/{user_id}/posts/{post_id}")] -async fn get_user_post(path: web::Path<(i64, i64)>) -> impl Responder { - let (user_id, post_id) = path.into_inner(); - HttpResponse::Ok().json(PostResponse { - user_id, - post_id, - title: "Benchmark Post".to_string(), - content: "This is a test post for benchmarking".to_string(), - }) -} - -#[post("/users")] -async fn create_user(body: web::Json) -> impl Responder { - HttpResponse::Ok().json(UserResponse { - id: 1, - name: body.name.clone(), - email: body.email.clone(), - created_at: "2024-01-01T00:00:00Z".to_string(), - is_active: true, - }) -} - -#[get("/users")] -async fn list_users() -> impl Responder { - let users: Vec = (1..=10) - .map(|id| UserResponse { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - created_at: "2024-01-01T00:00:00Z".to_string(), - is_active: id % 2 == 0, - }) - .collect(); - - HttpResponse::Ok().json(UsersListResponse { - total: 100, - page: 1, - users, - }) -} - -// ============================================ -// Main -// ============================================ - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - println!("🚀 Actix-web Benchmark Server (for comparison)"); - println!("═══════════════════════════════════════════════════════════"); - println!(); - println!("📊 Benchmark Endpoints:"); - println!(" GET / - Plain text (baseline)"); - println!(" GET /json - Simple JSON"); - println!(" GET /users/:id - JSON + path param"); - println!(" GET /users/:uid/posts/:pid - JSON + 2 path params"); - println!(" POST /users - JSON parsing"); - println!(" GET /users - Large JSON (10 users)"); - println!(); - println!("🔧 Load Test Commands:"); - println!(" hey -n 100000 -c 50 http://127.0.0.1:8081/"); - println!(" hey -n 100000 -c 50 http://127.0.0.1:8081/json"); - println!(); - println!("═══════════════════════════════════════════════════════════"); - println!("🌐 Server running at: http://127.0.0.1:8081"); - println!(); - - HttpServer::new(|| { - App::new() - .service(hello) - .service(json_hello) - .service(get_user) - .service(get_user_post) - .service(create_user) - .service(list_users) - }) - .bind("127.0.0.1:8081")? - .run() - .await -} diff --git a/benches/bench_server/Cargo.toml b/benches/bench_server/Cargo.toml deleted file mode 100644 index b8b4e15..0000000 --- a/benches/bench_server/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "bench-server" -version.workspace = true -edition.workspace = true -publish = false - -[[bin]] -name = "bench-server" -path = "src/main.rs" - -[dependencies] -# RustAPI with minimum features for benchmarking: -# - No swagger-ui overhead -# - No tracing overhead -# - simd-json for faster JSON parsing -rustapi-rs = { workspace = true, default-features = false, features = ["simd-json"] } -tokio.workspace = true -serde.workspace = true -serde_json.workspace = true -validator.workspace = true -utoipa.workspace = true diff --git a/benches/bench_server/src/main.rs b/benches/bench_server/src/main.rs deleted file mode 100644 index fd6685b..0000000 --- a/benches/bench_server/src/main.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! RustAPI Benchmark Server -//! -//! A minimal server for HTTP load testing (hey, wrk, etc.) -//! Optimized for maximum performance benchmarks. -//! -//! Run with: cargo run --release -p bench-server -//! Then test with: hey -n 100000 -c 50 http://127.0.0.1:8080/ - -use rustapi_rs::prelude::*; - -#[derive(Serialize, Schema)] -struct HelloResponse { - message: &'static str, -} - -#[derive(Serialize, Schema)] -struct UserResponse { - id: i64, - name: String, - email: String, - created_at: &'static str, - is_active: bool, -} - -#[derive(Serialize, Schema)] -struct UsersListResponse { - users: Vec, - total: usize, - page: usize, -} - -#[derive(Serialize, Schema)] -struct PostResponse { - post_id: i64, - title: &'static str, - content: &'static str, -} - -#[derive(Deserialize, Validate, Schema)] -struct CreateUser { - #[validate(length(min = 1, max = 100))] - name: String, - #[validate(email)] - email: String, -} - -// ============================================ -// Handlers - Optimized for benchmarks -// ============================================ - -/// Plain text response - baseline (zero allocation) -#[rustapi_rs::get("/")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("Plain text hello")] -async fn hello() -> &'static str { - "Hello, World!" -} - -/// Simple JSON response - pre-serialized bytes -#[rustapi_rs::get("/json")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("JSON hello")] -async fn json_hello() -> Json { - Json(HelloResponse { - message: "Hello, World!", - }) -} - -/// JSON response with path parameter -#[rustapi_rs::get("/users/{id}")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("Get user by ID")] -async fn get_user(Path(id): Path) -> Json { - Json(UserResponse { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - created_at: "2024-01-01T00:00:00Z", - is_active: true, - }) -} - -/// JSON response with path parameter -#[rustapi_rs::get("/posts/{id}")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("Get post by ID")] -async fn get_post(Path(id): Path) -> Json { - Json(PostResponse { - post_id: id, - title: "Benchmark Post", - content: "This is a test post for benchmarking", - }) -} - -/// JSON request body parsing with validation -#[rustapi_rs::post("/create-user")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("Create user with validation")] -async fn create_user(ValidatedJson(body): ValidatedJson) -> Json { - Json(UserResponse { - id: 1, - name: body.name, - email: body.email, - created_at: "2024-01-01T00:00:00Z", - is_active: true, - }) -} - -/// Larger JSON response (10 users) -#[rustapi_rs::get("/users-list")] -#[rustapi_rs::tag("Benchmark")] -#[rustapi_rs::summary("List users (10 items)")] -async fn list_users() -> Json { - let users: Vec = (1..=10) - .map(|id| UserResponse { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - created_at: "2024-01-01T00:00:00Z", - is_active: id % 2 == 0, - }) - .collect(); - - Json(UsersListResponse { - total: 100, - page: 1, - users, - }) -} - -// ============================================ -// Main - Optimized minimal server -// ============================================ - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Minimal output for benchmarks - eprintln!("🚀 RustAPI Benchmark Server @ http://127.0.0.1:8080"); - - RustApi::new() - .mount_route(hello_route()) - .mount_route(json_hello_route()) - .mount_route(get_user_route()) - .mount_route(get_post_route()) - .mount_route(create_user_route()) - .mount_route(list_users_route()) - .run("127.0.0.1:8080") - .await -} diff --git a/benches/json_bench.rs b/benches/json_bench.rs deleted file mode 100644 index ae40d73..0000000 --- a/benches/json_bench.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! JSON serialization/deserialization benchmarks -//! -//! Benchmarks serde_json performance which is critical for API frameworks. - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use serde::{Deserialize, Serialize}; - -/// Simple response structure -#[derive(Serialize, Deserialize)] -struct SimpleResponse { - message: String, - status: u16, -} - -/// User response with more fields -#[derive(Serialize, Deserialize)] -struct UserResponse { - id: i64, - name: String, - email: String, - created_at: String, - is_active: bool, -} - -/// Complex response with nested data -#[derive(Serialize, Deserialize)] -struct ComplexResponse { - users: Vec, - total: usize, - page: usize, - per_page: usize, - has_more: bool, -} - -/// Create test data -fn create_simple() -> SimpleResponse { - SimpleResponse { - message: "Hello, World!".to_string(), - status: 200, - } -} - -fn create_user(id: i64) -> UserResponse { - UserResponse { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - created_at: "2024-01-01T00:00:00Z".to_string(), - is_active: true, - } -} - -fn create_complex(count: usize) -> ComplexResponse { - ComplexResponse { - users: (0..count as i64).map(create_user).collect(), - total: count * 10, - page: 1, - per_page: count, - has_more: true, - } -} - -/// Benchmark JSON serialization -fn bench_serialize(c: &mut Criterion) { - let mut group = c.benchmark_group("json_serialize"); - - let simple = create_simple(); - let user = create_user(1); - let complex_10 = create_complex(10); - let complex_100 = create_complex(100); - - group.bench_function("simple", |b| { - b.iter(|| serde_json::to_string(black_box(&simple))) - }); - - group.bench_function("user", |b| { - b.iter(|| serde_json::to_string(black_box(&user))) - }); - - group.bench_function("complex_10_users", |b| { - b.iter(|| serde_json::to_string(black_box(&complex_10))) - }); - - group.bench_function("complex_100_users", |b| { - b.iter(|| serde_json::to_string(black_box(&complex_100))) - }); - - group.finish(); -} - -/// Benchmark JSON serialization to bytes (more realistic for HTTP) -fn bench_serialize_to_vec(c: &mut Criterion) { - let mut group = c.benchmark_group("json_serialize_vec"); - - let simple = create_simple(); - let user = create_user(1); - let complex_10 = create_complex(10); - - group.bench_function("simple", |b| { - b.iter(|| serde_json::to_vec(black_box(&simple))) - }); - - group.bench_function("user", |b| b.iter(|| serde_json::to_vec(black_box(&user)))); - - group.bench_function("complex_10_users", |b| { - b.iter(|| serde_json::to_vec(black_box(&complex_10))) - }); - - group.finish(); -} - -/// Benchmark JSON deserialization -fn bench_deserialize(c: &mut Criterion) { - let mut group = c.benchmark_group("json_deserialize"); - - let simple_json = serde_json::to_string(&create_simple()).unwrap(); - let user_json = serde_json::to_string(&create_user(1)).unwrap(); - let complex_10_json = serde_json::to_string(&create_complex(10)).unwrap(); - let complex_100_json = serde_json::to_string(&create_complex(100)).unwrap(); - - group.bench_function("simple", |b| { - b.iter(|| serde_json::from_str::(black_box(&simple_json))) - }); - - group.bench_function("user", |b| { - b.iter(|| serde_json::from_str::(black_box(&user_json))) - }); - - group.bench_function("complex_10_users", |b| { - b.iter(|| serde_json::from_str::(black_box(&complex_10_json))) - }); - - group.bench_function("complex_100_users", |b| { - b.iter(|| serde_json::from_str::(black_box(&complex_100_json))) - }); - - group.finish(); -} - -/// Benchmark request body parsing (typical API scenario) -fn bench_request_parsing(c: &mut Criterion) { - let mut group = c.benchmark_group("request_body_parsing"); - - // Simulate incoming request bodies - let create_user_body = r#"{"name": "John Doe", "email": "john@example.com"}"#; - let create_post_body = r#"{"title": "Hello World", "content": "This is a blog post with some content that is reasonably long to simulate real world usage.", "author_id": 123}"#; - let bulk_import_body = serde_json::to_string( - &(0..50) - .map(|i| { - serde_json::json!({ - "name": format!("User {}", i), - "email": format!("user{}@example.com", i) - }) - }) - .collect::>(), - ) - .unwrap(); - - #[derive(Deserialize)] - #[allow(dead_code)] - struct CreateUser { - name: String, - email: String, - } - - #[derive(Deserialize)] - #[allow(dead_code)] - struct CreatePost { - title: String, - content: String, - author_id: i64, - } - - group.bench_function("create_user", |b| { - b.iter(|| serde_json::from_str::(black_box(create_user_body))) - }); - - group.bench_function("create_post", |b| { - b.iter(|| serde_json::from_str::(black_box(create_post_body))) - }); - - group.bench_function("bulk_import_50", |b| { - b.iter(|| serde_json::from_str::>(black_box(&bulk_import_body))) - }); - - group.finish(); -} - -/// Benchmark scaling with response size -fn bench_response_scaling(c: &mut Criterion) { - let mut group = c.benchmark_group("response_scaling"); - - for user_count in [1, 10, 50, 100, 500].iter() { - let response = create_complex(*user_count); - - group.bench_with_input( - BenchmarkId::new("serialize", user_count), - user_count, - |b, _| b.iter(|| serde_json::to_vec(black_box(&response))), - ); - } - - group.finish(); -} - -criterion_group!( - benches, - bench_serialize, - bench_serialize_to_vec, - bench_deserialize, - bench_request_parsing, - bench_response_scaling, -); - -criterion_main!(benches); diff --git a/benches/routing_bench.rs b/benches/routing_bench.rs deleted file mode 100644 index 0646ff7..0000000 --- a/benches/routing_bench.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Routing micro-benchmarks using Criterion -//! -//! Benchmarks the core routing performance of RustAPI's matchit-based router. - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use matchit::Router; - -/// Benchmark static route matching -fn bench_static_routes(c: &mut Criterion) { - let mut router = Router::new(); - - // Add static routes - router.insert("/", "root").unwrap(); - router.insert("/health", "health").unwrap(); - router.insert("/api/v1/users", "users").unwrap(); - router.insert("/api/v1/posts", "posts").unwrap(); - router.insert("/api/v1/comments", "comments").unwrap(); - router.insert("/api/v2/users", "users_v2").unwrap(); - router.insert("/api/v2/posts", "posts_v2").unwrap(); - - let mut group = c.benchmark_group("static_routing"); - - group.bench_function("match_root", |b| b.iter(|| router.at(black_box("/")))); - - group.bench_function("match_health", |b| { - b.iter(|| router.at(black_box("/health"))) - }); - - group.bench_function("match_nested_v1", |b| { - b.iter(|| router.at(black_box("/api/v1/users"))) - }); - - group.bench_function("match_nested_v2", |b| { - b.iter(|| router.at(black_box("/api/v2/posts"))) - }); - - group.finish(); -} - -/// Benchmark dynamic route matching with path parameters -fn bench_dynamic_routes(c: &mut Criterion) { - let mut router = Router::new(); - - router.insert("/users/{id}", "get_user").unwrap(); - router - .insert("/users/{id}/posts", "get_user_posts") - .unwrap(); - router - .insert("/users/{user_id}/posts/{post_id}", "get_user_post") - .unwrap(); - router - .insert( - "/users/{user_id}/posts/{post_id}/comments/{comment_id}", - "get_comment", - ) - .unwrap(); - router - .insert( - "/categories/{cat}/products/{prod}/reviews/{rev}", - "get_review", - ) - .unwrap(); - - let mut group = c.benchmark_group("dynamic_routing"); - - group.bench_function("single_param", |b| { - b.iter(|| router.at(black_box("/users/123"))) - }); - - group.bench_function("single_param_nested", |b| { - b.iter(|| router.at(black_box("/users/123/posts"))) - }); - - group.bench_function("two_params", |b| { - b.iter(|| router.at(black_box("/users/123/posts/456"))) - }); - - group.bench_function("three_params", |b| { - b.iter(|| router.at(black_box("/users/123/posts/456/comments/789"))) - }); - - group.finish(); -} - -/// Benchmark router scaling with many routes -fn bench_router_scaling(c: &mut Criterion) { - let mut group = c.benchmark_group("router_scaling"); - - for route_count in [10, 50, 100, 500].iter() { - let mut router = Router::new(); - - for i in 0..*route_count { - router.insert(&format!("/api/v1/resource{}", i), i).unwrap(); - } - - // Always match the middle route - let search_path = format!("/api/v1/resource{}", route_count / 2); - - group.bench_with_input( - BenchmarkId::new("lookup", route_count), - route_count, - |b, _| b.iter(|| router.at(black_box(&search_path))), - ); - } - - group.finish(); -} - -/// Benchmark wildcard routes -fn bench_wildcard_routes(c: &mut Criterion) { - let mut router = Router::new(); - - router.insert("/static/{*path}", "static_files").unwrap(); - router.insert("/assets/{*filepath}", "assets").unwrap(); - - let mut group = c.benchmark_group("wildcard_routing"); - - group.bench_function("short_path", |b| { - b.iter(|| router.at(black_box("/static/css/style.css"))) - }); - - group.bench_function("long_path", |b| { - b.iter(|| router.at(black_box("/static/images/icons/social/facebook.png"))) - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_static_routes, - bench_dynamic_routes, - bench_router_scaling, - bench_wildcard_routes, -); - -criterion_main!(benches); diff --git a/benches/run_benchmarks.ps1 b/benches/run_benchmarks.ps1 deleted file mode 100644 index 764dc2e..0000000 --- a/benches/run_benchmarks.ps1 +++ /dev/null @@ -1,119 +0,0 @@ -# RustAPI Benchmarking Suite - -param( - [int]$Requests = 100000, - [int]$Concurrency = 50, - [switch]$SkipActix = $false, - [switch]$Internal = $false, # Run internal cargo bench - [switch]$Quick = $false # Quick smoke test mode -) - -$ErrorActionPreference = "Continue" - -Write-Host "===================================================================" -ForegroundColor Cyan -Write-Host " Running RustAPI Performance Benchmarking" -ForegroundColor Yellow -Write-Host "===================================================================" -ForegroundColor Cyan - -if ($Quick) { - $Requests = 1000 - $Concurrency = 10 - Write-Host "Running in Quick Mode (1000 reqs, 10 conn)" -ForegroundColor Magenta -} - -# --------------------------------------------------------- -# 1. Internal Benchmarks (Criterion) -# --------------------------------------------------------- -if ($Internal -or $Quick) { - Write-Host "`n[1/2] Running Internal Micro-benchmarks (cargo bench)..." -ForegroundColor Yellow - # If quick, we might want to filter or run fewer, but cargo bench is usually fast enough or hard to param - cargo bench --workspace -} else { - Write-Host "`n[1/2] Skipping Internal Benchmarks (use -Internal to run)" -ForegroundColor DarkGray -} - -# --------------------------------------------------------- -# 2. End-to-End API Benchmarks (Hey) -# --------------------------------------------------------- -Write-Host "`n[2/2] Running E2E API Benchmarks (hey)..." -ForegroundColor Yellow - -# Check if hey is installed -if (-not (Get-Command "hey" -ErrorAction SilentlyContinue)) { - $goHey = Join-Path $HOME "go\bin\hey.exe" - if (Test-Path $goHey) { - function Run-Hey { & $goHey @args } - } else { - Write-Host "X 'hey' is not installed! Skipping E2E benchmarks." -ForegroundColor Red - Write-Host "Install with: go install github.com/rakyll/hey@latest" - exit 0 # Don't fail entire script if only hey is missing, unless it's strictly required - } -} else { - function Run-Hey { & hey @args } -} - -# Build servers -Write-Host "Building servers in release mode..." -ForegroundColor Yellow -cargo build --release -p bench-server 2>&1 | Out-Null -if (-not $SkipActix) { - cargo build --release -p actix-bench-server 2>&1 | Out-Null -} -Write-Host "Build complete!" -ForegroundColor Green - -$results = @{} - -function Run-Benchmark { - param ([string]$Name, [string]$Framework, [string]$Url, [string]$Method = "GET", [string]$Body = $null) - - Write-Host " Testing: $Name" -ForegroundColor White - $heyArgs = @("-n", $Requests, "-c", $Concurrency) - if ($Method -eq "POST" -and $Body) { - $heyArgs += @("-m", "POST", "-H", "Content-Type: application/json", "-d", $Body) - } - $heyArgs += $Url - - $output = Run-Hey @heyArgs 2>&1 | Out-String - - $rps = 0; $avgLatency = 0 - if ($output -match "Requests/sec:\s+([\d.]+)") { $rps = $Matches[1] } - if ($output -match "Average:\s+([\d.]+)\s+secs") { $avgLatency = $Matches[1] } - - if ($rps -gt 0) { - $key = "$Framework|$Name" - $results[$key] = @{ Framework = $Framework; Endpoint = $Name; RPS = [double]$rps; AvgLatency = [double]$avgLatency * 1000 } - Write-Host " -> $rps req/s, avg: $([math]::Round([double]$avgLatency * 1000, 2))ms" -ForegroundColor Gray - } -} - -function Test-Framework { - param ([string]$Name, [string]$Port) - Write-Host "`nTesting $Name on port $Port" -ForegroundColor Cyan - - $retries = 10 - while ($retries -gt 0) { - try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/" -TimeoutSec 1 -ErrorAction Stop -UseBasicParsing; break } - catch { Start-Sleep -Milliseconds 500; $retries-- } - } - if ($retries -eq 0) { Write-Host "X Server not responding on port $Port" -ForegroundColor Red; return } - - Run-Benchmark -Name "Plain Text" -Framework $Name -Url "http://127.0.0.1:$Port/" - Run-Benchmark -Name "JSON Hello" -Framework $Name -Url "http://127.0.0.1:$Port/json" - - if ($Name -eq "RustAPI") { - Run-Benchmark -Name "POST JSON" -Framework $Name -Url "http://127.0.0.1:$Port/create-user" -Method "POST" -Body '{"name":"Test User","email":"test@example.com"}' - } else { - Run-Benchmark -Name "POST JSON" -Framework $Name -Url "http://127.0.0.1:$Port/users" -Method "POST" -Body '{"name":"Test User","email":"test@example.com"}' - } -} - -# Start RustAPI -$rustApiProcess = Start-Process -FilePath ".\target\release\bench-server.exe" -PassThru -WindowStyle Hidden -Start-Sleep -Seconds 2 -try { Test-Framework -Name "RustAPI" -Port "8080" } finally { Stop-Process -Id $rustApiProcess.Id -Force -ErrorAction SilentlyContinue } - -# Start Actix -if (-not $SkipActix) { - $actixProcess = Start-Process -FilePath ".\target\release\actix-bench-server.exe" -PassThru -WindowStyle Hidden - Start-Sleep -Seconds 2 - try { Test-Framework -Name "Actix-web" -Port "8081" } finally { Stop-Process -Id $actixProcess.Id -Force -ErrorAction SilentlyContinue } -} - -Write-Host "`nBenchmarks Complete." -ForegroundColor Green diff --git a/benches/rustapi_bench/Cargo.toml b/benches/rustapi_bench/Cargo.toml deleted file mode 100644 index 7bd0837..0000000 --- a/benches/rustapi_bench/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "rustapi-bench" -version.workspace = true -edition.workspace = true -publish = false - -[[bench]] -name = "middleware_bench" -harness = false - -[[bench]] -name = "extractor_bench" -harness = false - -[[bench]] -name = "websocket_bench" -harness = false - -[dependencies] -serde.workspace = true -serde_json.workspace = true - -[dev-dependencies] -criterion.workspace = true -serde_urlencoded = "0.7" diff --git a/benches/rustapi_bench/benches/extractor_bench.rs b/benches/rustapi_bench/benches/extractor_bench.rs deleted file mode 100644 index 6876c62..0000000 --- a/benches/rustapi_bench/benches/extractor_bench.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Extractor overhead benchmarks -//! -//! Benchmarks the performance of different extractor types in RustAPI. - -#![allow(dead_code)] - -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Simple query params struct -#[derive(Deserialize)] -struct SimpleQuery { - page: Option, - limit: Option, -} - -/// Complex query params struct -#[derive(Deserialize)] -struct ComplexQuery { - page: Option, - limit: Option, - sort: Option, - filter: Option, - include: Option>, -} - -/// User request body -#[derive(Serialize, Deserialize)] -struct UserBody { - name: String, - email: String, - age: u32, -} - -/// Complex request body -#[derive(Serialize, Deserialize)] -struct ComplexBody { - user: UserBody, - tags: Vec, - metadata: HashMap, -} - -/// Benchmark path parameter extraction -fn bench_path_extraction(c: &mut Criterion) { - let mut group = c.benchmark_group("path_extraction"); - - // Single path param - group.bench_function("single_param", |b| { - let path = "/users/12345"; - b.iter(|| { - let id: u64 = black_box(path) - .strip_prefix("/users/") - .unwrap() - .parse() - .unwrap(); - id - }) - }); - - // Multiple path params - group.bench_function("multiple_params", |b| { - let path = "/users/12345/posts/67890"; - b.iter(|| { - let parts: Vec<&str> = black_box(path).split('/').collect(); - let user_id: u64 = parts[2].parse().unwrap(); - let post_id: u64 = parts[4].parse().unwrap(); - (user_id, post_id) - }) - }); - - // UUID path param - group.bench_function("uuid_param", |b| { - let path = "/items/550e8400-e29b-41d4-a716-446655440000"; - b.iter(|| { - let uuid_str = black_box(path).strip_prefix("/items/").unwrap(); - // Just validate format, don't parse to actual UUID - uuid_str.len() == 36 && uuid_str.chars().filter(|c| *c == '-').count() == 4 - }) - }); - - group.finish(); -} - -/// Benchmark query string extraction -fn bench_query_extraction(c: &mut Criterion) { - let mut group = c.benchmark_group("query_extraction"); - - // Simple query - let simple_query = "page=1&limit=10"; - group.bench_function("simple_query", |b| { - b.iter(|| serde_urlencoded::from_str::(black_box(simple_query)).unwrap()) - }); - - // Complex query - let complex_query = - "page=1&limit=10&sort=created_at&filter=active&include=posts&include=comments"; - group.bench_function("complex_query", |b| { - b.iter(|| serde_urlencoded::from_str::(black_box(complex_query)).unwrap()) - }); - - // Empty query - let empty_query = ""; - group.bench_function("empty_query", |b| { - b.iter(|| serde_urlencoded::from_str::(black_box(empty_query)).unwrap()) - }); - - group.finish(); -} - -/// Benchmark JSON body extraction -fn bench_json_extraction(c: &mut Criterion) { - let mut group = c.benchmark_group("json_extraction"); - - // Simple body - let simple_json = r#"{"name":"John Doe","email":"john@example.com","age":30}"#; - group.bench_function("simple_body", |b| { - b.iter(|| serde_json::from_str::(black_box(simple_json)).unwrap()) - }); - - // Complex body - let complex_json = r#"{ - "user": {"name":"John Doe","email":"john@example.com","age":30}, - "tags": ["rust", "api", "web"], - "metadata": {"source": "mobile", "version": "1.0"} - }"#; - group.bench_function("complex_body", |b| { - b.iter(|| serde_json::from_str::(black_box(complex_json)).unwrap()) - }); - - // Large array body - let users: Vec = (0..100) - .map(|i| UserBody { - name: format!("User {}", i), - email: format!("user{}@example.com", i), - age: 20 + (i as u32 % 50), - }) - .collect(); - let large_json = serde_json::to_string(&users).unwrap(); - - group.bench_function("large_array_body", |b| { - b.iter(|| serde_json::from_str::>(black_box(&large_json)).unwrap()) - }); - - group.finish(); -} - -/// Benchmark header extraction -fn bench_header_extraction(c: &mut Criterion) { - let mut group = c.benchmark_group("header_extraction"); - - // Content-Type extraction - group.bench_function("content_type", |b| { - let header = "application/json; charset=utf-8"; - b.iter(|| { - let content_type = black_box(header).split(';').next().unwrap().trim(); - content_type == "application/json" - }) - }); - - // Authorization extraction - group.bench_function("authorization", |b| { - let header = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"; - b.iter(|| { - let token = black_box(header).strip_prefix("Bearer ").unwrap(); - token.len() > 0 - }) - }); - - // Accept header parsing - group.bench_function("accept_parsing", |b| { - let header = "application/json, application/xml;q=0.9, text/html;q=0.8, */*;q=0.1"; - b.iter(|| { - let types: Vec<&str> = black_box(header) - .split(',') - .map(|s| s.split(';').next().unwrap().trim()) - .collect(); - types - }) - }); - - group.finish(); -} - -/// Benchmark combined extraction (typical request) -fn bench_combined_extraction(c: &mut Criterion) { - let mut group = c.benchmark_group("combined_extraction"); - - // Typical GET request - group.bench_function("typical_get", |b| { - let path = "/users/12345"; - let query = "page=1&limit=10"; - let auth = "Bearer token123"; - - b.iter(|| { - // Extract path param - let user_id: u64 = black_box(path) - .strip_prefix("/users/") - .unwrap() - .parse() - .unwrap(); - - // Extract query params - let query_params = serde_urlencoded::from_str::(black_box(query)).unwrap(); - - // Extract auth token - let token = black_box(auth).strip_prefix("Bearer ").unwrap(); - - (user_id, query_params.page, token.len()) - }) - }); - - // Typical POST request - group.bench_function("typical_post", |b| { - let _path = "/users"; - let body = r#"{"name":"John Doe","email":"john@example.com","age":30}"#; - let content_type = "application/json"; - let auth = "Bearer token123"; - - b.iter(|| { - // Verify content type - let is_json = black_box(content_type) == "application/json"; - - // Extract auth token - let token = black_box(auth).strip_prefix("Bearer ").unwrap(); - - // Parse body - let user = serde_json::from_str::(black_box(body)).unwrap(); - - (is_json, token.len(), user.name.len()) - }) - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_path_extraction, - bench_query_extraction, - bench_json_extraction, - bench_header_extraction, - bench_combined_extraction, -); - -criterion_main!(benches); diff --git a/benches/rustapi_bench/benches/middleware_bench.rs b/benches/rustapi_bench/benches/middleware_bench.rs deleted file mode 100644 index 68fdaf3..0000000 --- a/benches/rustapi_bench/benches/middleware_bench.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Middleware composition benchmarks -//! -//! Benchmarks the overhead of middleware layers in RustAPI. - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; - -/// Simulate middleware overhead with simple counter -fn simulate_middleware_layer(input: u64, layers: usize) -> u64 { - let mut result = input; - for _ in 0..layers { - // Simulate minimal middleware work: check + transform - if result > 0 { - result = result.wrapping_add(1); - } - } - result -} - -/// Simulate request ID generation (UUID-like) -fn simulate_request_id_middleware(request_count: u64) -> String { - format!("req_{:016x}", request_count) -} - -/// Simulate header parsing overhead -fn simulate_header_parsing(headers: &[(&str, &str)]) -> usize { - headers.iter().map(|(k, v)| k.len() + v.len()).sum() -} - -/// Benchmark middleware layer composition -fn bench_middleware_layers(c: &mut Criterion) { - let mut group = c.benchmark_group("middleware_layers"); - - // Test with different numbers of middleware layers - for layer_count in [0, 1, 3, 5, 10, 20].iter() { - group.bench_with_input( - BenchmarkId::new("layer_count", layer_count), - layer_count, - |b, &layers| b.iter(|| simulate_middleware_layer(black_box(42), layers)), - ); - } - - group.finish(); -} - -/// Benchmark request ID generation -fn bench_request_id(c: &mut Criterion) { - let mut group = c.benchmark_group("request_id"); - - group.bench_function("generate", |b| { - let mut counter = 0u64; - b.iter(|| { - counter += 1; - simulate_request_id_middleware(black_box(counter)) - }) - }); - - group.finish(); -} - -/// Benchmark header parsing -fn bench_header_parsing(c: &mut Criterion) { - let mut group = c.benchmark_group("header_parsing"); - - // Minimal headers - let minimal_headers = [("content-type", "application/json")]; - - // Typical API headers - let typical_headers = [ - ("content-type", "application/json"), - ("accept", "application/json"), - ( - "authorization", - "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - ), - ("x-request-id", "550e8400-e29b-41d4-a716-446655440000"), - ("user-agent", "RustAPI-Client/1.0"), - ]; - - // Many headers - let many_headers: Vec<(&str, &str)> = (0..20) - .map(|i| { - let key: &'static str = Box::leak(format!("x-custom-header-{}", i).into_boxed_str()); - let value: &'static str = Box::leak(format!("value-{}", i).into_boxed_str()); - (key, value) - }) - .collect(); - - group.bench_function("minimal_headers", |b| { - b.iter(|| simulate_header_parsing(black_box(&minimal_headers))) - }); - - group.bench_function("typical_headers", |b| { - b.iter(|| simulate_header_parsing(black_box(&typical_headers))) - }); - - group.bench_function("many_headers", |b| { - b.iter(|| simulate_header_parsing(black_box(&many_headers))) - }); - - group.finish(); -} - -/// Benchmark async middleware simulation -fn bench_middleware_chain(c: &mut Criterion) { - let mut group = c.benchmark_group("middleware_chain"); - - // Simulate a typical middleware chain: - // 1. Request ID - // 2. Tracing - // 3. Auth check - // 4. Rate limit check - // 5. Body limit check - - group.bench_function("typical_chain", |b| { - b.iter(|| { - // Step 1: Generate request ID - let request_id = simulate_request_id_middleware(black_box(12345)); - - // Step 2: Tracing (record span) - let _ = black_box(request_id.len()); - - // Step 3: Auth check (simple token validation) - let token = "Bearer valid_token"; - let is_valid = black_box(token.starts_with("Bearer ")); - - // Step 4: Rate limit check (counter check) - let rate_count = black_box(99u64); - let under_limit = rate_count < 100; - - // Step 5: Body limit check - let body_size = black_box(1024usize); - let within_limit = body_size < 1_048_576; // 1MB - - (is_valid, under_limit, within_limit) - }) - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_middleware_layers, - bench_request_id, - bench_header_parsing, - bench_middleware_chain, -); - -criterion_main!(benches); diff --git a/benches/rustapi_bench/benches/websocket_bench.rs b/benches/rustapi_bench/benches/websocket_bench.rs deleted file mode 100644 index 68707db..0000000 --- a/benches/rustapi_bench/benches/websocket_bench.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! WebSocket message throughput benchmarks -//! -//! Benchmarks the performance of WebSocket message handling in RustAPI. - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; -use std::collections::HashMap; - -/// Simulate WebSocket message parsing (text) -fn parse_text_message(data: &str) -> String { - data.to_string() -} - -/// Simulate WebSocket message parsing (binary) -fn parse_binary_message(data: &[u8]) -> Vec { - data.to_vec() -} - -/// Simulate JSON message parsing -fn parse_json_message(data: &str) -> serde_json::Value { - serde_json::from_str(data).unwrap_or(serde_json::Value::Null) -} - -/// Simulate message frame encoding -fn encode_frame(opcode: u8, payload: &[u8], mask: bool) -> Vec { - let mut frame = Vec::with_capacity(14 + payload.len()); - - // FIN bit + opcode - frame.push(0x80 | opcode); - - // Payload length - let len = payload.len(); - if len < 126 { - frame.push((if mask { 0x80 } else { 0 }) | len as u8); - } else if len < 65536 { - frame.push((if mask { 0x80 } else { 0 }) | 126); - frame.push((len >> 8) as u8); - frame.push(len as u8); - } else { - frame.push((if mask { 0x80 } else { 0 }) | 127); - for i in (0..8).rev() { - frame.push((len >> (i * 8)) as u8); - } - } - - // Masking key (if masked) - if mask { - let mask_key: [u8; 4] = [0x12, 0x34, 0x56, 0x78]; - frame.extend_from_slice(&mask_key); - - // Masked payload - for (i, byte) in payload.iter().enumerate() { - frame.push(byte ^ mask_key[i % 4]); - } - } else { - frame.extend_from_slice(payload); - } - - frame -} - -/// Benchmark text message parsing -fn bench_text_message(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_text"); - - let messages = [ - ("tiny", "Hi"), - ("small", "Hello, WebSocket!"), - ("medium", &"x".repeat(1024)), - ("large", &"x".repeat(64 * 1024)), - ]; - - for (name, msg) in messages.iter() { - group.throughput(Throughput::Bytes(msg.len() as u64)); - group.bench_with_input(BenchmarkId::new("parse", name), msg, |b, msg| { - b.iter(|| parse_text_message(black_box(msg))) - }); - } - - group.finish(); -} - -/// Benchmark binary message parsing -fn bench_binary_message(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_binary"); - - let messages: Vec<(&str, Vec)> = vec![ - ("tiny", vec![1, 2, 3, 4]), - ("small", vec![0u8; 64]), - ("medium", vec![0u8; 4096]), - ("large", vec![0u8; 64 * 1024]), - ]; - - for (name, msg) in messages.iter() { - group.throughput(Throughput::Bytes(msg.len() as u64)); - group.bench_with_input(BenchmarkId::new("parse", name), msg, |b, msg| { - b.iter(|| parse_binary_message(black_box(msg))) - }); - } - - group.finish(); -} - -/// Benchmark JSON message parsing (common WebSocket pattern) -fn bench_json_message(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_json"); - - // Simple JSON message - let simple_json = r#"{"type":"ping"}"#; - - // Typical chat message - let chat_json = - r#"{"type":"message","user":"alice","content":"Hello everyone!","timestamp":1704067200}"#; - - // Complex nested JSON - let complex_json = r#"{"type":"state","data":{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"room":"general","active":true}}"#; - - group.bench_function("simple", |b| { - b.iter(|| parse_json_message(black_box(simple_json))) - }); - - group.bench_function("chat", |b| { - b.iter(|| parse_json_message(black_box(chat_json))) - }); - - group.bench_function("complex", |b| { - b.iter(|| parse_json_message(black_box(complex_json))) - }); - - group.finish(); -} - -/// Benchmark frame encoding -fn bench_frame_encoding(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_frame"); - - let payloads: Vec<(&str, Vec)> = vec![ - ("tiny", vec![1, 2, 3, 4]), - ("small", vec![0u8; 100]), - ("medium_125", vec![0u8; 125]), // Max single-byte length - ("medium_126", vec![0u8; 126]), // Requires 2-byte length - ("large", vec![0u8; 1024]), - ]; - - for (name, payload) in payloads.iter() { - // Server-side (no mask) - group.bench_with_input( - BenchmarkId::new("encode_unmasked", name), - payload, - |b, payload| b.iter(|| encode_frame(0x01, black_box(payload), false)), - ); - - // Client-side (with mask) - group.bench_with_input( - BenchmarkId::new("encode_masked", name), - payload, - |b, payload| b.iter(|| encode_frame(0x01, black_box(payload), true)), - ); - } - - group.finish(); -} - -/// Benchmark broadcast scenario (sending to multiple clients) -fn bench_broadcast(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_broadcast"); - - let message = "Broadcast message to all connected clients"; - - for client_count in [10, 100, 1000].iter() { - group.bench_with_input( - BenchmarkId::new("prepare_messages", client_count), - client_count, - |b, &count| { - b.iter(|| { - // Simulate preparing messages for N clients - let mut messages = Vec::with_capacity(count); - for _ in 0..count { - messages.push(black_box(message).to_string()); - } - messages - }) - }, - ); - } - - group.finish(); -} - -/// Benchmark connection management (HashMap-based room pattern) -fn bench_connection_management(c: &mut Criterion) { - let mut group = c.benchmark_group("websocket_rooms"); - - // Simulate room-based connection management - group.bench_function("join_room", |b| { - let mut rooms: HashMap> = HashMap::new(); - let mut client_id = 0u64; - - b.iter(|| { - client_id += 1; - let room = black_box("general".to_string()); - rooms.entry(room).or_default().push(client_id); - }) - }); - - group.bench_function("leave_room", |b| { - let mut rooms: HashMap> = HashMap::new(); - rooms.insert("general".to_string(), (0..1000).collect()); - - b.iter(|| { - let room = rooms.get_mut(black_box("general")).unwrap(); - let client_id = black_box(500u64); - if let Some(pos) = room.iter().position(|&id| id == client_id) { - room.swap_remove(pos); - } - }) - }); - - group.bench_function("list_room_members", |b| { - let mut rooms: HashMap> = HashMap::new(); - rooms.insert("general".to_string(), (0..100).collect()); - - b.iter(|| rooms.get(black_box("general")).map(|members| members.len())) - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_text_message, - bench_binary_message, - bench_json_message, - bench_frame_encoding, - bench_broadcast, - bench_connection_management, -); - -criterion_main!(benches); diff --git a/benches/rustapi_bench/src/lib.rs b/benches/rustapi_bench/src/lib.rs deleted file mode 100644 index f4154a5..0000000 --- a/benches/rustapi_bench/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder for library diff --git a/benches/test_body.json b/benches/test_body.json deleted file mode 100644 index 3205461..0000000 --- a/benches/test_body.json +++ /dev/null @@ -1 +0,0 @@ -{"message":"Hello, World!"} \ No newline at end of file diff --git a/benches/toon_bench/Cargo.toml b/benches/toon_bench/Cargo.toml deleted file mode 100644 index 1ab5f6c..0000000 --- a/benches/toon_bench/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "toon-bench" -version.workspace = true -edition.workspace = true -publish = false - -[[bench]] -name = "toon_bench" -harness = false - -[dependencies] -serde.workspace = true -serde_json.workspace = true -toon-format.workspace = true - -[dev-dependencies] -criterion.workspace = true diff --git a/benches/toon_bench/benches/toon_bench.rs b/benches/toon_bench/benches/toon_bench.rs deleted file mode 100644 index 9b01674..0000000 --- a/benches/toon_bench/benches/toon_bench.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! TOON Format Benchmarks -//! -//! Benchmarks comparing TOON vs JSON performance: -//! - Serialization speed -//! - Deserialization speed -//! - Output size -//! - Token count estimation -//! -//! Run with: cargo bench --package toon-bench - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct User { - id: u64, - name: String, - role: String, - active: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct UsersResponse { - users: Vec, - total: usize, - page: usize, -} - -fn create_users(count: usize) -> Vec { - (1..=count) - .map(|i| User { - id: i as u64, - name: format!("User{}", i), - role: if i % 3 == 0 { - "admin".into() - } else { - "user".into() - }, - active: i % 2 == 0, - }) - .collect() -} - -fn create_response(user_count: usize) -> UsersResponse { - let users = create_users(user_count); - UsersResponse { - total: users.len(), - users, - page: 1, - } -} - -fn benchmark_serialization(c: &mut Criterion) { - let mut group = c.benchmark_group("serialization"); - - for size in [10, 50, 100, 500, 1000].iter() { - let response = create_response(*size); - - group.bench_with_input(BenchmarkId::new("json", size), size, |b, _| { - b.iter(|| { - let _ = black_box(serde_json::to_string(&response)); - }); - }); - - group.bench_with_input(BenchmarkId::new("toon", size), size, |b, _| { - b.iter(|| { - let _ = black_box(toon_format::encode_default(&response)); - }); - }); - } - - group.finish(); -} - -fn benchmark_deserialization(c: &mut Criterion) { - let mut group = c.benchmark_group("deserialization"); - - for size in [10, 50, 100].iter() { - let response = create_response(*size); - let json_str = serde_json::to_string(&response).unwrap(); - - group.bench_with_input(BenchmarkId::new("json", size), &json_str, |b, json| { - b.iter(|| { - let _: UsersResponse = black_box(serde_json::from_str(json).unwrap()); - }); - }); - } - - group.finish(); -} - -fn benchmark_output_size(c: &mut Criterion) { - let mut group = c.benchmark_group("output_size"); - - for size in [10, 50, 100, 500, 1000].iter() { - let response = create_response(*size); - let json_str = serde_json::to_string(&response).unwrap(); - let toon_str = toon_format::encode_default(&response).unwrap(); - - // Just measure sizes (not really a benchmark, more like a comparison) - println!("\n=== {} users ===", size); - println!("JSON bytes: {}", json_str.len()); - println!("TOON bytes: {}", toon_str.len()); - println!( - "Byte savings: {:.2}%", - (1.0 - (toon_str.len() as f64 / json_str.len() as f64)) * 100.0 - ); - - // Estimate tokens (~4 chars per token) - let json_tokens = json_str.len().div_ceil(4); - let toon_tokens = toon_str.len().div_ceil(4); - println!("JSON tokens (est): {}", json_tokens); - println!("TOON tokens (est): {}", toon_tokens); - println!( - "Token savings: {:.2}%", - (1.0 - (toon_tokens as f64 / json_tokens as f64)) * 100.0 - ); - - // Benchmark the size calculation itself (trivial) - group.bench_with_input(BenchmarkId::new("json_len", size), &json_str, |b, s| { - b.iter(|| black_box(s.len())); - }); - group.bench_with_input(BenchmarkId::new("toon_len", size), &toon_str, |b, s| { - b.iter(|| black_box(s.len())); - }); - } - - group.finish(); -} - -fn benchmark_roundtrip(c: &mut Criterion) { - let mut group = c.benchmark_group("roundtrip"); - - for size in [10, 50, 100].iter() { - let response = create_response(*size); - - group.bench_with_input(BenchmarkId::new("json", size), size, |b, _| { - b.iter(|| { - let json = serde_json::to_string(&response).unwrap(); - let _: UsersResponse = serde_json::from_str(&json).unwrap(); - }); - }); - } - - group.finish(); -} - -criterion_group!( - benches, - benchmark_serialization, - benchmark_deserialization, - benchmark_output_size, - benchmark_roundtrip, -); -criterion_main!(benches); diff --git a/benches/toon_bench/src/lib.rs b/benches/toon_bench/src/lib.rs deleted file mode 100644 index 865556c..0000000 --- a/benches/toon_bench/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder lib for benchmark crate diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index 380a9d0..0672bbd 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -309,7 +309,7 @@ impl RustApi { /// RustApi::new() /// .register_schema::() /// ``` - pub fn register_schema rustapi_openapi::Schema<'a>>(mut self) -> Self { + pub fn register_schema(mut self) -> Self { self.openapi_spec = self.openapi_spec.register::(); self } @@ -1183,34 +1183,39 @@ fn add_path_params_to_operation( } } +/// Convert a schema type string to an OpenAPI schema reference /// Convert a schema type string to an OpenAPI schema reference fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef { + use rustapi_openapi::schema::{Schema, SchemaType}; + match schema_type.to_lowercase().as_str() { - "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string", - "format": "uuid" - })), - "integer" | "int" | "int64" | "i64" => { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int64" - })) - } - "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int32" - })), - "number" | "float" | "f64" | "f32" => { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "number" - })) - } - "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "boolean" - })), - _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string" - })), + "uuid" => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::String), + format: Some("uuid".to_string()), + ..Default::default() + }), + "integer" | "int" | "int64" | "i64" => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::Integer), + format: Some("int64".to_string()), + ..Default::default() + }), + "int32" | "i32" => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::Integer), + format: Some("int32".to_string()), + ..Default::default() + }), + "number" | "float" | "f64" | "f32" => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::Number), + ..Default::default() + }), + "boolean" | "bool" => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::Boolean), + ..Default::default() + }), + _ => rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::String), + ..Default::default() + }), } } @@ -1223,16 +1228,19 @@ fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRe /// - `year`, `month`, `day` → integer /// - Everything else → string fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef { + use rustapi_openapi::schema::{Schema, SchemaType}; + let lower = name.to_lowercase(); // UUID patterns (check first to avoid false positive from "id" suffix) let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid"); if is_uuid { - return rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string", - "format": "uuid" - })); + return rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::String), + format: Some("uuid".to_string()), + ..Default::default() + }); } // Integer patterns @@ -1250,12 +1258,16 @@ fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef { || lower == "position"; if is_integer { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int64" - })) + rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::Integer), + format: Some("int64".to_string()), + ..Default::default() + }) } else { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })) + rustapi_openapi::SchemaRef::T(Schema { + schema_type: Some(SchemaType::String), + ..Default::default() + }) } } @@ -1331,6 +1343,7 @@ mod tests { #[test] fn test_path_param_type_inference_integer() { use super::infer_path_param_schema; + use rustapi_openapi::schema::SchemaType; // Test common integer patterns let int_params = [ @@ -1349,10 +1362,10 @@ mod tests { for name in int_params { let schema = infer_path_param_schema(name); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { + rustapi_openapi::SchemaRef::T(s) => { assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("integer"), + s.schema_type, + Some(SchemaType::Integer), "Expected '{}' to be inferred as integer", name ); @@ -1365,6 +1378,7 @@ mod tests { #[test] fn test_path_param_type_inference_uuid() { use super::infer_path_param_schema; + use rustapi_openapi::schema::SchemaType; // Test UUID patterns let uuid_params = ["uuid", "user_uuid", "sessionUuid"]; @@ -1372,16 +1386,16 @@ mod tests { for name in uuid_params { let schema = infer_path_param_schema(name); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { + rustapi_openapi::SchemaRef::T(s) => { assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("string"), + s.schema_type, + Some(SchemaType::String), "Expected '{}' to be inferred as string", name ); assert_eq!( - v.get("format").and_then(|v| v.as_str()), - Some("uuid"), + s.format, + Some("uuid".to_string()), "Expected '{}' to have uuid format", name ); @@ -1394,6 +1408,7 @@ mod tests { #[test] fn test_path_param_type_inference_string() { use super::infer_path_param_schema; + use rustapi_openapi::schema::SchemaType; // Test string (default) patterns let string_params = [ @@ -1403,16 +1418,15 @@ mod tests { for name in string_params { let schema = infer_path_param_schema(name); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { + rustapi_openapi::SchemaRef::T(s) => { assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("string"), + s.schema_type, + Some(SchemaType::String), "Expected '{}' to be inferred as string", name ); assert!( - v.get("format").is_none() - || v.get("format").and_then(|v| v.as_str()) != Some("uuid"), + s.format.is_none() || s.format.as_deref() != Some("uuid"), "Expected '{}' to NOT have uuid format", name ); @@ -1425,13 +1439,14 @@ mod tests { #[test] fn test_schema_type_to_openapi_schema() { use super::schema_type_to_openapi_schema; + use rustapi_openapi::schema::SchemaType; // Test UUID schema let uuid_schema = schema_type_to_openapi_schema("uuid"); match uuid_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert_eq!(s.format, Some("uuid".to_string())); } _ => panic!("Expected inline schema for uuid"), } @@ -1440,9 +1455,9 @@ mod tests { for schema_type in ["integer", "int", "int64", "i64"] { let schema = schema_type_to_openapi_schema(schema_type); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Integer)); + assert_eq!(s.format, Some("int64".to_string())); } _ => panic!("Expected inline schema for {}", schema_type), } @@ -1451,9 +1466,9 @@ mod tests { // Test int32 schema let int32_schema = schema_type_to_openapi_schema("int32"); match int32_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Integer)); + assert_eq!(s.format, Some("int32".to_string())); } _ => panic!("Expected inline schema for int32"), } @@ -1462,8 +1477,8 @@ mod tests { for schema_type in ["number", "float"] { let schema = schema_type_to_openapi_schema(schema_type); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Number)); } _ => panic!("Expected inline schema for {}", schema_type), } @@ -1473,8 +1488,8 @@ mod tests { for schema_type in ["boolean", "bool"] { let schema = schema_type_to_openapi_schema(schema_type); match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Boolean)); } _ => panic!("Expected inline schema for {}", schema_type), } @@ -1483,8 +1498,8 @@ mod tests { // Test string schema (default) let string_schema = schema_type_to_openapi_schema("string"); match string_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); + rustapi_openapi::SchemaRef::T(s) => { + assert_eq!(s.schema_type, Some(SchemaType::String)); } _ => panic!("Expected inline schema for string"), } diff --git a/crates/rustapi-core/src/extract.rs b/crates/rustapi-core/src/extract.rs index be3b881..1697dd1 100644 --- a/crates/rustapi-core/src/extract.rs +++ b/crates/rustapi-core/src/extract.rs @@ -62,6 +62,10 @@ use crate::stream::{StreamingBody, StreamingConfig}; use crate::validation::Validatable; use bytes::Bytes; use http::{header, StatusCode}; +use rustapi_openapi::{ + IntoParams, MediaType, Operation, OperationModifier, ParameterIn, RequestBody, + ResponseModifier, ResponseSpec, SchemaRef, ToSchema, +}; use rustapi_validate::v2::{AsyncValidate, ValidationContext}; use serde::de::DeserializeOwned; @@ -960,20 +964,37 @@ impl_from_request_parts_for_primitives!( // OperationModifier implementations for extractors -use rustapi_openapi::utoipa_types::openapi; -use rustapi_openapi::{ - IntoParams, MediaType, Operation, OperationModifier, Parameter, RequestBody, ResponseModifier, - ResponseSpec, Schema, SchemaRef, -}; use std::collections::HashMap; // ValidatedJson - Adds request body -impl Schema<'a>> OperationModifier for ValidatedJson { +impl OperationModifier for ValidatedJson { fn update_operation(op: &mut Operation) { - let (name, _) = T::schema(); + let (name, ref_or_schema) = T::schema(); + + let schema_ref = match ref_or_schema { + SchemaRef::Ref(r) => SchemaRef::Ref(r), + SchemaRef::T(s) => { + let should_inline = match s.schema_type { + Some(rustapi_openapi::schema::SchemaType::Array) + | Some(rustapi_openapi::schema::SchemaType::Boolean) + | Some(rustapi_openapi::schema::SchemaType::Integer) + | Some(rustapi_openapi::schema::SchemaType::Number) + | Some(rustapi_openapi::schema::SchemaType::String) => true, + Some(rustapi_openapi::schema::SchemaType::Object) => { + s.properties.as_ref().map_or(true, |p| p.is_empty()) + && s.enum_values.as_ref().map_or(true, |e| e.is_empty()) + } + None => true, + }; - let schema_ref = SchemaRef::Ref { - reference: format!("#/components/schemas/{}", name), + if should_inline { + SchemaRef::T(s) + } else { + SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: format!("#/components/schemas/{}", name), + }) + } + } }; let mut content = HashMap::new(); @@ -997,9 +1018,9 @@ impl Schema<'a>> OperationModifier for ValidatedJson { map.insert( "application/json".to_string(), MediaType { - schema: SchemaRef::Ref { - reference: "#/components/schemas/ValidationErrorSchema".to_string(), - }, + schema: SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: "#/components/schemas/ValidationErrorSchema".to_string(), + }), }, ); Some(map) @@ -1010,12 +1031,34 @@ impl Schema<'a>> OperationModifier for ValidatedJson { } // Json - Adds request body (Same as ValidatedJson) -impl Schema<'a>> OperationModifier for Json { +impl OperationModifier for Json { fn update_operation(op: &mut Operation) { - let (name, _) = T::schema(); + let (name, ref_or_schema) = T::schema(); + + let schema_ref = match ref_or_schema { + SchemaRef::Ref(r) => SchemaRef::Ref(r), + SchemaRef::T(s) => { + let should_inline = match s.schema_type { + Some(rustapi_openapi::schema::SchemaType::Array) + | Some(rustapi_openapi::schema::SchemaType::Boolean) + | Some(rustapi_openapi::schema::SchemaType::Integer) + | Some(rustapi_openapi::schema::SchemaType::Number) + | Some(rustapi_openapi::schema::SchemaType::String) => true, + Some(rustapi_openapi::schema::SchemaType::Object) => { + s.properties.as_ref().map_or(true, |p| p.is_empty()) + && s.enum_values.as_ref().map_or(true, |e| e.is_empty()) + } + None => true, + }; - let schema_ref = SchemaRef::Ref { - reference: format!("#/components/schemas/{}", name), + if should_inline { + SchemaRef::T(s) + } else { + SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: format!("#/components/schemas/{}", name), + }) + } + } }; let mut content = HashMap::new(); @@ -1055,43 +1098,11 @@ impl OperationModifier for Typed { // Query - Extracts query params using IntoParams impl OperationModifier for Query { fn update_operation(op: &mut Operation) { - let params = T::into_params(|| Some(openapi::path::ParameterIn::Query)); - - let new_params: Vec = params - .into_iter() - .map(|p| { - let schema = match p.schema { - Some(schema) => match schema { - openapi::RefOr::Ref(r) => SchemaRef::Ref { - reference: r.ref_location, - }, - openapi::RefOr::T(s) => { - let value = serde_json::to_value(s).unwrap_or(serde_json::Value::Null); - SchemaRef::Inline(value) - } - }, - None => SchemaRef::Inline(serde_json::Value::Null), - }; - - let required = match p.required { - openapi::Required::True => true, - openapi::Required::False => false, - }; - - Parameter { - name: p.name, - location: "query".to_string(), // explicitly query - required, - description: p.description, - schema, - } - }) - .collect(); - + let params = T::into_params(|| Some(ParameterIn::Query)); if let Some(existing) = &mut op.parameters { - existing.extend(new_params); + existing.extend(params); } else { - op.parameters = Some(new_params); + op.parameters = Some(params); } } } @@ -1108,9 +1119,11 @@ impl OperationModifier for Body { content.insert( "application/octet-stream".to_string(), MediaType { - schema: SchemaRef::Inline( - serde_json::json!({ "type": "string", "format": "binary" }), - ), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + format: Some("binary".to_string()), + ..Default::default() + }), }, ); @@ -1128,9 +1141,11 @@ impl OperationModifier for BodyStream { content.insert( "application/octet-stream".to_string(), MediaType { - schema: SchemaRef::Inline( - serde_json::json!({ "type": "string", "format": "binary" }), - ), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + format: Some("binary".to_string()), + ..Default::default() + }), }, ); @@ -1144,12 +1159,34 @@ impl OperationModifier for BodyStream { // ResponseModifier implementations for extractors // Json - 200 OK with schema T -impl Schema<'a>> ResponseModifier for Json { +impl ResponseModifier for Json { fn update_response(op: &mut Operation) { - let (name, _) = T::schema(); + let (name, ref_or_schema) = T::schema(); + + let schema_ref = match ref_or_schema { + SchemaRef::Ref(r) => SchemaRef::Ref(r), + SchemaRef::T(s) => { + let should_inline = match s.schema_type { + Some(rustapi_openapi::schema::SchemaType::Array) + | Some(rustapi_openapi::schema::SchemaType::Boolean) + | Some(rustapi_openapi::schema::SchemaType::Integer) + | Some(rustapi_openapi::schema::SchemaType::Number) + | Some(rustapi_openapi::schema::SchemaType::String) => true, + Some(rustapi_openapi::schema::SchemaType::Object) => { + s.properties.as_ref().map_or(true, |p| p.is_empty()) + && s.enum_values.as_ref().map_or(true, |e| e.is_empty()) + } + None => true, + }; - let schema_ref = SchemaRef::Ref { - reference: format!("#/components/schemas/{}", name), + if should_inline { + SchemaRef::T(s) + } else { + SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: format!("#/components/schemas/{}", name), + }) + } + } }; op.responses.insert( diff --git a/crates/rustapi-core/src/response.rs b/crates/rustapi-core/src/response.rs index 8a81837..473b7c2 100644 --- a/crates/rustapi-core/src/response.rs +++ b/crates/rustapi-core/src/response.rs @@ -75,7 +75,7 @@ use bytes::Bytes; use futures_util::StreamExt; use http::{header, HeaderMap, HeaderValue, StatusCode}; use http_body_util::Full; -use rustapi_openapi::{MediaType, Operation, ResponseModifier, ResponseSpec, Schema, SchemaRef}; +use rustapi_openapi::{MediaType, Operation, ResponseModifier, ResponseSpec, SchemaRef, ToSchema}; use serde::Serialize; use std::collections::HashMap; use std::pin::Pin; @@ -293,9 +293,9 @@ impl ResponseModifier for ApiError { map.insert( "application/json".to_string(), MediaType { - schema: SchemaRef::Ref { - reference: "#/components/schemas/ErrorSchema".to_string(), - }, + schema: SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: "#/components/schemas/ErrorSchema".to_string(), + }), }, ); Some(map) @@ -313,9 +313,9 @@ impl ResponseModifier for ApiError { map.insert( "application/json".to_string(), MediaType { - schema: SchemaRef::Ref { - reference: "#/components/schemas/ErrorSchema".to_string(), - }, + schema: SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: "#/components/schemas/ErrorSchema".to_string(), + }), }, ); Some(map) @@ -355,12 +355,34 @@ impl IntoResponse for Created { } } -impl Schema<'a>> ResponseModifier for Created { +impl ResponseModifier for Created { fn update_response(op: &mut Operation) { - let (name, _) = T::schema(); - - let schema_ref = SchemaRef::Ref { - reference: format!("#/components/schemas/{}", name), + let (name, ref_or_schema) = T::schema(); + + let schema_ref = match ref_or_schema { + SchemaRef::Ref(r) => SchemaRef::Ref(r), + SchemaRef::T(s) => { + let should_inline = match s.schema_type { + Some(rustapi_openapi::schema::SchemaType::Array) + | Some(rustapi_openapi::schema::SchemaType::Boolean) + | Some(rustapi_openapi::schema::SchemaType::Integer) + | Some(rustapi_openapi::schema::SchemaType::Number) + | Some(rustapi_openapi::schema::SchemaType::String) => true, + Some(rustapi_openapi::schema::SchemaType::Object) => { + s.properties.as_ref().map_or(true, |p| p.is_empty()) + && s.enum_values.as_ref().map_or(true, |e| e.is_empty()) + } + None => true, + }; + + if should_inline { + SchemaRef::T(s) + } else { + SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: format!("#/components/schemas/{}", name), + }) + } + } }; op.responses.insert( @@ -441,7 +463,10 @@ impl ResponseModifier for Html { map.insert( "text/html".to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + ..Default::default() + }), }, ); Some(map) @@ -539,13 +564,13 @@ impl IntoResponse for WithStatus { } } -impl Schema<'a>, const CODE: u16> ResponseModifier for WithStatus { +impl ResponseModifier for WithStatus { fn update_response(op: &mut Operation) { let (name, _) = T::schema(); - let schema_ref = SchemaRef::Ref { - reference: format!("#/components/schemas/{}", name), - }; + let schema_ref = SchemaRef::Ref(rustapi_openapi::schema::Reference { + ref_path: format!("#/components/schemas/{}", name), + }); op.responses.insert( CODE.to_string(), diff --git a/crates/rustapi-core/src/sse.rs b/crates/rustapi-core/src/sse.rs index 333cf45..1a336da 100644 --- a/crates/rustapi-core/src/sse.rs +++ b/crates/rustapi-core/src/sse.rs @@ -396,11 +396,12 @@ impl ResponseModifier for Sse { content.insert( "text/event-stream".to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "string", - "description": "Server-Sent Events stream. Events follow the SSE format: 'event: \\ndata: \\n\\n'", - "example": "event: message\ndata: {\"id\": 1, \"text\": \"Hello\"}\n\n" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + description: Some("Server-Sent Events stream. Events follow the SSE format: 'event: \\ndata: \\n\\n'".to_string()), + example: Some(serde_json::json!("event: message\ndata: {\"id\": 1, \"text\": \"Hello\"}\n\n")), + ..Default::default() + }), }, ); diff --git a/crates/rustapi-extras/src/jwt/mod.rs b/crates/rustapi-extras/src/jwt/mod.rs index 13da498..465dc70 100644 --- a/crates/rustapi-extras/src/jwt/mod.rs +++ b/crates/rustapi-extras/src/jwt/mod.rs @@ -334,6 +334,7 @@ impl FromRequestParts for AuthUser { impl OperationModifier for AuthUser { fn update_operation(op: &mut Operation) { // Add 401 Unauthorized response to OpenAPI spec + use rustapi_openapi::schema::Reference; use rustapi_openapi::{MediaType, ResponseSpec, SchemaRef}; use std::collections::HashMap; @@ -346,9 +347,9 @@ impl OperationModifier for AuthUser { map.insert( "application/json".to_string(), MediaType { - schema: SchemaRef::Ref { - reference: "#/components/schemas/ErrorSchema".to_string(), - }, + schema: SchemaRef::Ref(Reference { + ref_path: "#/components/schemas/ErrorSchema".to_string(), + }), }, ); Some(map) diff --git a/crates/rustapi-macros/src/lib.rs b/crates/rustapi-macros/src/lib.rs index 8ae21dc..766f700 100644 --- a/crates/rustapi-macros/src/lib.rs +++ b/crates/rustapi-macros/src/lib.rs @@ -23,6 +23,43 @@ use syn::{ }; mod api_error; +mod schema; + +/// Derive macro for OpenAPI Schema generation +/// +/// # Example +/// +/// ```rust,ignore +/// #[derive(ToSchema)] +/// struct User { +/// id: i32, +/// name: String, +/// } +/// ``` +#[proc_macro_derive(ToSchema, attributes(schema, serde))] +pub fn derive_to_schema(input: TokenStream) -> TokenStream { + schema::derive_to_schema(input) +} + +mod params; + +/// Derive macro for OpenAPI Parameters generation (IntoParams) +/// +/// Use this on query parameter structs. +/// +/// # Example +/// +/// ```rust,ignore +/// #[derive(IntoParams, Deserialize)] +/// struct Pagination { +/// page: Option, +/// limit: Option, +/// } +/// ``` +#[proc_macro_derive(IntoParams, attributes(serde))] +pub fn derive_into_params(input: TokenStream) -> TokenStream { + params::derive_into_params(input) +} /// Auto-register a schema type for zero-config OpenAPI. /// diff --git a/crates/rustapi-macros/src/params.rs b/crates/rustapi-macros/src/params.rs new file mode 100644 index 0000000..8d35ae8 --- /dev/null +++ b/crates/rustapi-macros/src/params.rs @@ -0,0 +1,112 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Attribute, Data, DeriveInput, Expr, Fields, Lit, Meta}; + +pub fn derive_into_params(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let params_impl = match &input.data { + Data::Struct(s) => expand_struct_params(s), + _ => { + return syn::Error::new_spanned(name, "IntoParams only supported for structs") + .to_compile_error() + .into(); + } + }; + + let expanded = quote! { + impl #impl_generics ::rustapi_rs::__private::rustapi_openapi::IntoParams for #name #ty_generics #where_clause { + fn into_params(parameter_in_provider: impl Fn() -> Option<::rustapi_rs::__private::rustapi_openapi::ParameterIn>) -> Vec<::rustapi_rs::__private::rustapi_openapi::Parameter> { + let default_location = ::rustapi_rs::__private::rustapi_openapi::ParameterIn::Query; + let location_enum = parameter_in_provider().unwrap_or(default_location); + let location_str = location_enum.to_string(); + + #params_impl + } + } + }; + + expanded.into() +} + +fn expand_struct_params(data: &syn::DataStruct) -> TokenStream { + let mut params = Vec::new(); + + match &data.fields { + Fields::Named(fields) => { + for field in &fields.named { + let ident = field.ident.as_ref().unwrap(); + let ident_str = ident.to_string(); + + let mut param_name = ident_str; + let mut is_option = false; + + // Check for Option wrapper + if let syn::Type::Path(tp) = &field.ty { + if let Some(seg) = tp.path.segments.last() { + if seg.ident == "Option" { + is_option = true; + } + } + } + + // Handle serde rename + if let Some(renamed) = get_serde_rename(&field.attrs) { + param_name = renamed; + } + + let required = !is_option; + let ty = &field.ty; + + // Doc comments handling (simplified) + let description = quote! { None }; + + params.push(quote! { + ::rustapi_rs::__private::rustapi_openapi::Parameter { + name: #param_name.to_string(), + location: location_str.clone(), + required: #required, + description: #description, + schema: <#ty as ::rustapi_rs::__private::rustapi_openapi::ToSchema>::schema().1, + } + }); + } + } + _ => { + // Unnamed fields / Unit structs not supported for IntoParams (usually query params are named) + return quote! { vec![] }; + } + } + + quote! { + vec![ + #(#params),* + ] + } +} + +fn get_serde_rename(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + if let Ok(nested) = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) { + for meta in nested { + if let Meta::NameValue(nv) = meta { + if nv.path.is_ident("rename") { + if let Expr::Lit(lit) = nv.value { + if let Lit::Str(s) = lit.lit { + return Some(s.value()); + } + } + } + } + } + } + } + } + None +} diff --git a/crates/rustapi-macros/src/schema.rs b/crates/rustapi-macros/src/schema.rs new file mode 100644 index 0000000..d9cd14c --- /dev/null +++ b/crates/rustapi-macros/src/schema.rs @@ -0,0 +1,195 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse_macro_input, Attribute, Data, DataEnum, DataStruct, DeriveInput, Expr, Fields, + FieldsNamed, Lit, Meta, +}; + +pub fn derive_to_schema(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let name_str = name.to_string(); + + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let schema_impl = match &input.data { + Data::Struct(s) => expand_struct_schema(s, &name_str), + Data::Enum(e) => expand_enum_schema(e, &name_str), + Data::Union(_) => { + return syn::Error::new_spanned(name, "Unions are not supported for ToSchema") + .to_compile_error() + .into(); + } + }; + + let expanded = quote! { + impl #impl_generics ::rustapi_rs::__private::rustapi_openapi::ToSchema for #name #ty_generics #where_clause { + fn name() -> String { + #name_str.to_string() + } + + fn schema() -> (String, ::rustapi_rs::__private::rustapi_openapi::schema::RefOr<::rustapi_rs::__private::rustapi_openapi::schema::Schema>) { + #schema_impl + } + } + }; + + expanded.into() +} + +fn expand_struct_schema(data: &DataStruct, name: &str) -> TokenStream { + match &data.fields { + Fields::Named(fields) => expand_named_fields(fields, name), + Fields::Unnamed(_) => quote! { + // Tuple structs treated as array likely, or just empty? + // For now, treat as just named schema with no props? + // Actually, let's support them as simple objects if possible, or error. + // Simplified implementation: + ( + #name.to_string(), + ::rustapi_rs::__private::rustapi_openapi::schema::Schema { + schema_type: Some(::rustapi_rs::__private::rustapi_openapi::schema::SchemaType::Object), + description: None, + ..Default::default() + }.into() + ) + }, + Fields::Unit => quote! { + ( + #name.to_string(), + ::rustapi_rs::__private::rustapi_openapi::schema::Schema { + schema_type: Some(::rustapi_rs::__private::rustapi_openapi::schema::SchemaType::Object), // or null? + description: None, + ..Default::default() + }.into() + ) + }, + } +} + +fn expand_named_fields(fields: &FieldsNamed, name: &str) -> TokenStream { + let mut props = Vec::new(); + let mut required = Vec::new(); + + for field in &fields.named { + let field_name = field.ident.as_ref().unwrap(); + let mut field_name_str = field_name.to_string(); + + let mut is_option = false; + + // Check for Option wrapper to determine if required + // This is a naive check (string matching), could be improved with type analysis + // assuming standard Option usage + if let syn::Type::Path(tp) = &field.ty { + if let Some(seg) = tp.path.segments.last() { + if seg.ident == "Option" { + is_option = true; + } + } + } + + // Handle serde rename + if let Some(renamed) = get_serde_rename(&field.attrs) { + field_name_str = renamed; + } + + if !is_option { + required.push(quote! { #field_name_str.to_string() }); + } + + let ty = &field.ty; + + // Property schema generation + // We defer to ::schema() + // But for Option, we want T's schema. + // For Vec, we want Array of T. + // Our ToSchema impl for Option/Vec already handles structure, + // but we need to supply the reference. + + // If field type implements ToSchema, we can just use it? + // Yes, ToSchema::schema() returns (name, RefOr). + // If RefOr is Ref, we are good. + // If RefOr is T (inline), we embed it. + + props.push(quote! { + map.insert( + #field_name_str.to_string(), + <#ty as ::rustapi_rs::__private::rustapi_openapi::ToSchema>::schema().1 + ); + }); + } + + let required_quote = if required.is_empty() { + quote! { None } + } else { + quote! { Some(vec![#(#required),*]) } + }; + + quote! { + let mut map = ::std::collections::HashMap::new(); + #(#props)* + + ( + #name.to_string(), + ::rustapi_rs::__private::rustapi_openapi::schema::Schema { + schema_type: Some(::rustapi_rs::__private::rustapi_openapi::schema::SchemaType::Object), + properties: Some(map), + required: #required_quote, + ..Default::default() + }.into() + ) + } +} + +fn expand_enum_schema(data: &DataEnum, name: &str) -> TokenStream { + let mut variants = Vec::new(); + + // Simple enum (C-like) support for now (strings) + for variant in &data.variants { + let mut variant_name = variant.ident.to_string(); + // Handle serde rename + if let Some(renamed) = get_serde_rename(&variant.attrs) { + variant_name = renamed; + } + + variants.push(quote! { + ::serde_json::Value::String(#variant_name.to_string()) + }); + } + + quote! { + ( + #name.to_string(), + ::rustapi_rs::__private::rustapi_openapi::schema::Schema { + schema_type: Some(::rustapi_rs::__private::rustapi_openapi::schema::SchemaType::String), + enum_values: Some(vec![#(#variants),*]), + ..Default::default() + }.into() + ) + } +} + +fn get_serde_rename(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + // Parse #[serde(...)] + // Looking for rename = "name" + if let Ok(nested) = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) { + for meta in nested { + if let Meta::NameValue(nv) = meta { + if nv.path.is_ident("rename") { + if let Expr::Lit(lit) = nv.value { + if let Lit::Str(s) = lit.lit { + return Some(s.value()); + } + } + } + } + } + } + } + } + None +} diff --git a/crates/rustapi-openapi/Cargo.toml b/crates/rustapi-openapi/Cargo.toml index 34ac5d7..1bad30c 100644 --- a/crates/rustapi-openapi/Cargo.toml +++ b/crates/rustapi-openapi/Cargo.toml @@ -14,13 +14,14 @@ homepage.workspace = true http = { workspace = true } http-body-util = { workspace = true } bytes = { workspace = true } +uuid = { workspace = true, optional = true } # Serialization serde = { workspace = true } serde_json = { workspace = true } # OpenAPI schema generation -utoipa = { workspace = true } + @@ -28,3 +29,4 @@ utoipa = { workspace = true } default = ["swagger-ui"] swagger-ui = [] redoc = [] +uuid = ["dep:uuid"] diff --git a/crates/rustapi-openapi/src/lib.rs b/crates/rustapi-openapi/src/lib.rs index 8f74762..2f777f0 100644 --- a/crates/rustapi-openapi/src/lib.rs +++ b/crates/rustapi-openapi/src/lib.rs @@ -62,8 +62,9 @@ mod config; #[cfg(feature = "redoc")] mod redoc; -mod schemas; -mod spec; +pub mod schema; +pub mod schemas; +pub mod spec; #[cfg(feature = "swagger-ui")] mod swagger; @@ -74,25 +75,16 @@ pub mod v31; pub mod versioning; pub use config::OpenApiConfig; +pub use schema::{Schema, ToSchema}; pub use schemas::{ ErrorBodySchema, ErrorSchema, FieldErrorSchema, ValidationErrorBodySchema, ValidationErrorSchema, }; pub use spec::{ - ApiInfo, MediaType, OpenApiSpec, Operation, OperationModifier, Parameter, PathItem, - RequestBody, ResponseModifier, ResponseSpec, SchemaRef, + ApiInfo, IntoParams, MediaType, OpenApiSpec, Operation, OperationModifier, Parameter, + ParameterIn, PathItem, RequestBody, ResponseModifier, ResponseSpec, SchemaRef, }; -// Re-export utoipa's ToSchema derive macro as Schema -pub use utoipa::ToSchema as Schema; -// Re-export utoipa's IntoParams derive macro -pub use utoipa::IntoParams; - -// Re-export utoipa types for advanced usage -pub mod utoipa_types { - pub use utoipa::{openapi, IntoParams, Modify, OpenApi, ToSchema}; -} - use bytes::Bytes; use http::{header, Response, StatusCode}; use http_body_util::Full; diff --git a/crates/rustapi-openapi/src/schema.rs b/crates/rustapi-openapi/src/schema.rs new file mode 100644 index 0000000..e566d87 --- /dev/null +++ b/crates/rustapi-openapi/src/schema.rs @@ -0,0 +1,224 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Trait for types that can generate their own OpenAPI schema. +pub trait ToSchema { + /// Get the name of the schema (for ref) + fn name() -> String; + + /// Generate the schema object + fn schema() -> (String, RefOr); +} + +/// Reference or inline schema +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum RefOr { + Ref(Reference), + T(T), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Reference { + #[serde(rename = "$ref")] + pub ref_path: String, +} + +/// OpenAPI Schema Object +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Schema { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub schema_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SchemaType { + String, + Integer, + Number, + Boolean, + Object, + Array, +} + +impl From for RefOr { + fn from(t: T) -> Self { + RefOr::T(t) + } +} + +// Primitives Implementation + +impl ToSchema for String { + fn name() -> String { + "String".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "String".to_string(), + Schema { + schema_type: Some(SchemaType::String), + ..Default::default() + } + .into(), + ) + } +} + +impl ToSchema for &str { + fn name() -> String { + "String".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "String".to_string(), + Schema { + schema_type: Some(SchemaType::String), + ..Default::default() + } + .into(), + ) + } +} + +impl ToSchema for bool { + fn name() -> String { + "Boolean".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "Boolean".to_string(), + Schema { + schema_type: Some(SchemaType::Boolean), + ..Default::default() + } + .into(), + ) + } +} + +// Integer types +macro_rules! impl_int_schema { + ($($ty:ty),*) => { + $( + impl ToSchema for $ty { + fn name() -> String { + "Integer".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "Integer".to_string(), + Schema { + schema_type: Some(SchemaType::Integer), + format: if std::mem::size_of::<$ty>() > 4 { Some("int64".to_string()) } else { Some("int32".to_string()) }, + ..Default::default() + } + .into(), + ) + } + } + )* + }; +} + +impl_int_schema!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize); + +// Float types +macro_rules! impl_float_schema { + ($($ty:ty),*) => { + $( + impl ToSchema for $ty { + fn name() -> String { + "Number".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "Number".to_string(), + Schema { + schema_type: Some(SchemaType::Number), + format: if std::mem::size_of::<$ty>() > 4 { Some("double".to_string()) } else { Some("float".to_string()) }, + ..Default::default() + } + .into(), + ) + } + } + )* + }; +} + +impl_float_schema!(f32, f64); + +// Option +impl ToSchema for Option { + fn name() -> String { + T::name() + } + + fn schema() -> (String, RefOr) { + // Option doesn't change the schema structure in OpenAPI 3.0 usually, + // it just means it's not in 'required' list of parent object, or nullable: true + // For simplicity, we delegate to T + T::schema() + } +} + +// Vec +impl ToSchema for Vec { + fn name() -> String { + format!("Array_of_{}", T::name()) + } + + fn schema() -> (String, RefOr) { + let (_, item_schema) = T::schema(); + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Array), + items: Some(Box::new(item_schema)), + ..Default::default() + } + .into(), + ) + } +} + +// UUID support (if feature enabled, or just hardcode as string for now as it's common) +#[cfg(feature = "uuid")] +impl ToSchema for uuid::Uuid { + fn name() -> String { + "Uuid".to_string() + } + + fn schema() -> (String, RefOr) { + ( + "Uuid".to_string(), + Schema { + schema_type: Some(SchemaType::String), + format: Some("uuid".to_string()), + ..Default::default() + } + .into(), + ) + } +} diff --git a/crates/rustapi-openapi/src/schemas.rs b/crates/rustapi-openapi/src/schemas.rs index 79b6447..8d0c61f 100644 --- a/crates/rustapi-openapi/src/schemas.rs +++ b/crates/rustapi-openapi/src/schemas.rs @@ -3,10 +3,10 @@ //! These schemas match the error response format used by RustAPI. use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +// use crate::ToSchema; // TODO: Re-enable once macro is implemented in rustapi-macros /// Standard error response body -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorSchema { /// The error details pub error: ErrorBodySchema, @@ -16,7 +16,7 @@ pub struct ErrorSchema { } /// Error body details -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorBodySchema { /// Error type identifier (e.g., "validation_error", "not_found") #[serde(rename = "type")] @@ -29,7 +29,7 @@ pub struct ErrorBodySchema { } /// Field-level validation error -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldErrorSchema { /// Field name (supports nested paths like "address.city") pub field: String, @@ -40,14 +40,14 @@ pub struct FieldErrorSchema { } /// Validation error response (422) -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationErrorSchema { /// Error wrapper pub error: ValidationErrorBodySchema, } /// Validation error body -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationErrorBodySchema { /// Always "validation_error" for validation errors #[serde(rename = "type")] @@ -112,3 +112,176 @@ impl ErrorSchema { } } } + +// Manual ToSchema implementations + +impl crate::schema::ToSchema for ErrorSchema { + fn name() -> String { + "ErrorSchema".to_string() + } + + fn schema() -> (String, crate::schema::RefOr) { + use crate::schema::{Schema, SchemaType}; + let mut props = std::collections::HashMap::new(); + props.insert( + "error".to_string(), + ::schema().1, + ); + props.insert( + "request_id".to_string(), + as crate::schema::ToSchema>::schema().1, + ); + + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Object), + description: Some("Standard error response body".to_string()), + properties: Some(props), + required: Some(vec!["error".to_string()]), + ..Default::default() + } + .into(), + ) + } +} + +impl crate::schema::ToSchema for ErrorBodySchema { + fn name() -> String { + "ErrorBodySchema".to_string() + } + + fn schema() -> (String, crate::schema::RefOr) { + use crate::schema::{Schema, SchemaType}; + let mut props = std::collections::HashMap::new(); + props.insert( + "type".to_string(), + ::schema().1, + ); + props.insert( + "message".to_string(), + ::schema().1, + ); + props.insert( + "fields".to_string(), + > as crate::schema::ToSchema>::schema().1, + ); + + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Object), + description: Some("Error body details".to_string()), + properties: Some(props), + required: Some(vec!["type".to_string(), "message".to_string()]), + ..Default::default() + } + .into(), + ) + } +} + +impl crate::schema::ToSchema for FieldErrorSchema { + fn name() -> String { + "FieldErrorSchema".to_string() + } + + fn schema() -> (String, crate::schema::RefOr) { + use crate::schema::{Schema, SchemaType}; + let mut props = std::collections::HashMap::new(); + props.insert( + "field".to_string(), + ::schema().1, + ); + props.insert( + "code".to_string(), + ::schema().1, + ); + props.insert( + "message".to_string(), + ::schema().1, + ); + + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Object), + description: Some("Field-level validation error".to_string()), + properties: Some(props), + required: Some(vec![ + "field".to_string(), + "code".to_string(), + "message".to_string(), + ]), + ..Default::default() + } + .into(), + ) + } +} + +impl crate::schema::ToSchema for ValidationErrorSchema { + fn name() -> String { + "ValidationErrorSchema".to_string() + } + + fn schema() -> (String, crate::schema::RefOr) { + use crate::schema::{Schema, SchemaType}; + let mut props = std::collections::HashMap::new(); + props.insert( + "error".to_string(), + ::schema().1, + ); + + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Object), + description: Some("Validation error response".to_string()), + properties: Some(props), + required: Some(vec!["error".to_string()]), + ..Default::default() + } + .into(), + ) + } +} + +impl crate::schema::ToSchema for ValidationErrorBodySchema { + fn name() -> String { + "ValidationErrorBodySchema".to_string() + } + + fn schema() -> (String, crate::schema::RefOr) { + use crate::schema::{Schema, SchemaType}; + let mut props = std::collections::HashMap::new(); + props.insert( + "type".to_string(), + ::schema().1, + ); + props.insert( + "message".to_string(), + ::schema().1, + ); + props.insert( + "fields".to_string(), + as crate::schema::ToSchema>::schema().1, + ); + + ( + Self::name(), + Schema { + schema_type: Some(SchemaType::Object), + description: Some("Validation error body".to_string()), + properties: Some(props), + required: Some(vec![ + "type".to_string(), + "message".to_string(), + "fields".to_string(), + ]), + ..Default::default() + } + .into(), + ) + } +} diff --git a/crates/rustapi-openapi/src/spec.rs b/crates/rustapi-openapi/src/spec.rs index 5240d76..9607fd3 100644 --- a/crates/rustapi-openapi/src/spec.rs +++ b/crates/rustapi-openapi/src/spec.rs @@ -17,7 +17,7 @@ pub struct ApiInfo { pub struct OpenApiSpec { pub info: ApiInfo, pub paths: HashMap, - pub schemas: HashMap, + pub schemas: HashMap>, } /// Path item in OpenAPI spec @@ -52,6 +52,32 @@ pub struct Operation { pub responses: HashMap, } +/// Parameter location +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ParameterIn { + Query, + Header, + Path, + Cookie, +} + +impl std::fmt::Display for ParameterIn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query => write!(f, "query"), + Self::Header => write!(f, "header"), + Self::Path => write!(f, "path"), + Self::Cookie => write!(f, "cookie"), + } + } +} + +/// Trait for types that can populate OpenAPI parameters +pub trait IntoParams { + fn into_params(parameter_in_provider: impl Fn() -> Option) -> Vec; +} + /// Parameter in OpenAPI spec #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Parameter { @@ -86,15 +112,10 @@ pub struct ResponseSpec { } /// Schema reference or inline schema -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum SchemaRef { - Ref { - #[serde(rename = "$ref")] - reference: String, - }, - Inline(serde_json::Value), -} +/// +/// Legacy type alias to bridge old and new code if needed. +/// In new code, prefer crate::schema::RefOr +pub type SchemaRef = crate::schema::RefOr; impl OpenApiSpec { /// Create a new OpenAPI specification @@ -131,17 +152,19 @@ impl OpenApiSpec { } /// Add a schema definition - pub fn schema(mut self, name: &str, schema: serde_json::Value) -> Self { + pub fn schema( + mut self, + name: &str, + schema: crate::schema::RefOr, + ) -> Self { self.schemas.insert(name.to_string(), schema); self } - /// Register a type that implements Schema (utoipa::ToSchema) - pub fn register utoipa::ToSchema<'a>>(mut self) -> Self { + /// Register a type that implements Schema (crate::ToSchema) + pub fn register(mut self) -> Self { let (name, schema) = T::schema(); - if let Ok(json_schema) = serde_json::to_value(schema) { - self.schemas.insert(name.to_string(), json_schema); - } + self.schemas.insert(name, schema); self } @@ -149,11 +172,9 @@ impl OpenApiSpec { /// /// This is useful for zero-config registration paths where the spec is stored /// by value in another struct (e.g., the application builder). - pub fn register_in_place utoipa::ToSchema<'a>>(&mut self) { + pub fn register_in_place(&mut self) { let (name, schema) = T::schema(); - if let Ok(json_schema) = serde_json::to_value(schema) { - self.schemas.insert(name.to_string(), json_schema); - } + self.schemas.insert(name, schema); } /// Convert to JSON value @@ -284,7 +305,10 @@ impl ResponseModifier for String { content.insert( "text/plain".to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })), + schema: crate::schema::RefOr::T(crate::schema::Schema { + schema_type: Some(crate::schema::SchemaType::String), + ..Default::default() + }), }, ); @@ -303,7 +327,10 @@ impl ResponseModifier for &'static str { content.insert( "text/plain".to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })), + schema: crate::schema::RefOr::T(crate::schema::Schema { + schema_type: Some(crate::schema::SchemaType::String), + ..Default::default() + }), }, ); diff --git a/crates/rustapi-openapi/src/v31/spec.rs b/crates/rustapi-openapi/src/v31/spec.rs index fe6dc43..96ec7b6 100644 --- a/crates/rustapi-openapi/src/v31/spec.rs +++ b/crates/rustapi-openapi/src/v31/spec.rs @@ -159,19 +159,20 @@ impl OpenApi31Spec { self } - /// Register a type that implements utoipa::ToSchema + /// Register a type that implements crate::ToSchema /// /// The schema will be automatically transformed to OpenAPI 3.1 format - pub fn register utoipa::ToSchema<'a>>(mut self) -> Self { - let (name, schema) = T::schema(); - if let Ok(json_schema) = serde_json::to_value(schema) { + pub fn register(mut self) -> Self { + let (name, schema_struct) = T::schema(); + // Convert to JSON Value for transformation + if let Ok(json_schema) = serde_json::to_value(schema_struct) { let transformed = SchemaTransformer::transform_30_to_31(json_schema); if let Ok(schema31) = serde_json::from_value::(transformed) { let components = self.components.get_or_insert_with(Components31::default); components .schemas .get_or_insert_with(HashMap::new) - .insert(name.to_string(), schema31); + .insert(name, schema31); } } self diff --git a/crates/rustapi-rs/examples/openapi_demo.rs b/crates/rustapi-rs/examples/openapi_demo.rs new file mode 100644 index 0000000..bc534de --- /dev/null +++ b/crates/rustapi-rs/examples/openapi_demo.rs @@ -0,0 +1,43 @@ +//! # Hello World Example +//! +//! The minimal RustAPI application demonstrating core concepts. +//! +//! ## Demonstrates +//! - `RustApi::auto()` for automatic route discovery +//! - Path parameter extraction with `Path` +//! - JSON response serialization +//! - OpenAPI schema generation with `utoipa::ToSchema` +//! +//! ## Run +//! ```bash +//! cargo run -p hello-world +//! ``` +//! +//! ## Test +//! ```bash +//! curl http://127.0.0.1:8080/hello/World +//! ``` +//! +//! ## Cookbook +//! + +use rustapi_macros as rustapi; +use rustapi_rs::prelude::*; +use serde::Serialize; + +#[derive(Serialize, rustapi::ToSchema)] +struct Message { + greeting: String, +} + +#[rustapi::get("/hello/{name}")] +async fn hello(Path(name): Path) -> Json { + Json(Message { + greeting: format!("Hello, {name}!"), + }) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + RustApi::auto().run("0.0.0.0:8080").await +} diff --git a/crates/rustapi-toon/src/extractor.rs b/crates/rustapi-toon/src/extractor.rs index 27ea6a8..d945948 100644 --- a/crates/rustapi-toon/src/extractor.rs +++ b/crates/rustapi-toon/src/extractor.rs @@ -140,10 +140,13 @@ impl OperationModifier for Toon { content.insert( TOON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "string", - "description": "TOON (Token-Oriented Object Notation) formatted request body" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + description: Some( + "TOON (Token-Oriented Object Notation) formatted request body".to_string(), + ), + ..Default::default() + }), }, ); @@ -161,10 +164,13 @@ impl ResponseModifier for Toon { content.insert( TOON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "string", - "description": "TOON (Token-Oriented Object Notation) formatted response" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + description: Some( + "TOON (Token-Oriented Object Notation) formatted response".to_string(), + ), + ..Default::default() + }), }, ); diff --git a/crates/rustapi-toon/src/llm_response.rs b/crates/rustapi-toon/src/llm_response.rs index bc9c5ac..fc66ffc 100644 --- a/crates/rustapi-toon/src/llm_response.rs +++ b/crates/rustapi-toon/src/llm_response.rs @@ -233,10 +233,13 @@ impl ResponseModifier for LlmResponse { content.insert( JSON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "object", - "description": "JSON formatted response with token counting headers" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::Object), + description: Some( + "JSON formatted response with token counting headers".to_string(), + ), + ..Default::default() + }), }, ); @@ -244,10 +247,11 @@ impl ResponseModifier for LlmResponse { content.insert( TOON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "string", - "description": "TOON (Token-Oriented Object Notation) formatted response with token counting headers" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + description: Some("TOON (Token-Oriented Object Notation) formatted response with token counting headers".to_string()), + ..Default::default() + }), }, ); diff --git a/crates/rustapi-toon/src/negotiate.rs b/crates/rustapi-toon/src/negotiate.rs index 302842a..6aa4bda 100644 --- a/crates/rustapi-toon/src/negotiate.rs +++ b/crates/rustapi-toon/src/negotiate.rs @@ -286,10 +286,11 @@ impl ResponseModifier for Negotiate { content.insert( JSON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "object", - "description": "JSON formatted response" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::Object), + description: Some("JSON formatted response".to_string()), + ..Default::default() + }), }, ); @@ -297,10 +298,13 @@ impl ResponseModifier for Negotiate { content.insert( TOON_CONTENT_TYPE.to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ - "type": "string", - "description": "TOON (Token-Oriented Object Notation) formatted response" - })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + description: Some( + "TOON (Token-Oriented Object Notation) formatted response".to_string(), + ), + ..Default::default() + }), }, ); diff --git a/crates/rustapi-view/src/view.rs b/crates/rustapi-view/src/view.rs index 969bdf3..288e338 100644 --- a/crates/rustapi-view/src/view.rs +++ b/crates/rustapi-view/src/view.rs @@ -143,7 +143,10 @@ impl ResponseModifier for View { map.insert( "text/html".to_string(), MediaType { - schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })), + schema: SchemaRef::T(rustapi_openapi::schema::Schema { + schema_type: Some(rustapi_openapi::schema::SchemaType::String), + ..Default::default() + }), }, ); Some(map) diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 3f2b6d5..a1c1ed8 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -27,6 +27,7 @@ - [cargo-rustapi: The Architect](crates/cargo_rustapi.md) - [Part IV: Recipes](recipes/README.md) + - [Zero-Config OpenAPI](recipes/zero_config_openapi.md) - [Creating Resources](recipes/crud_resource.md) - [JWT Authentication](recipes/jwt_auth.md) - [CSRF Protection](recipes/csrf_protection.md) diff --git a/docs/cookbook/src/crates/rustapi_openapi.md b/docs/cookbook/src/crates/rustapi_openapi.md index a571bb3..b648872 100644 --- a/docs/cookbook/src/crates/rustapi_openapi.md +++ b/docs/cookbook/src/crates/rustapi_openapi.md @@ -4,9 +4,12 @@ **Philosophy**: "Documentation as Code." ## Automatic Spec Generation - + We believe that if documentation is manual, it is wrong. RustAPI uses `utoipa` to generate an OpenAPI 3.0 specification directly from your code. +> [!TIP] +> See the [Zero-Config OpenAPI](../recipes/zero_config_openapi.md) recipe for the modern, macro-based approach. + ## The `Schema` Trait Any type that is part of your API (request or response) must implement `Schema`. diff --git a/docs/cookbook/src/recipes/crud_resource.md b/docs/cookbook/src/recipes/crud_resource.md index fff9814..413ee82 100644 --- a/docs/cookbook/src/recipes/crud_resource.md +++ b/docs/cookbook/src/recipes/crud_resource.md @@ -35,9 +35,11 @@ pub async fn create(Json(payload): Json) -> impl IntoResponse { Then register it in `main.rs`: ```rust -RustApi::new() - .mount(handlers::users::list) - .mount(handlers::users::create) +// In main.rs +RustApi::auto() // Automatic registration! + .run("127.0.0.1:8080") + .await? + ``` ## Discussion diff --git a/docs/cookbook/src/recipes/zero_config_openapi.md b/docs/cookbook/src/recipes/zero_config_openapi.md new file mode 100644 index 0000000..f26355f --- /dev/null +++ b/docs/cookbook/src/recipes/zero_config_openapi.md @@ -0,0 +1,89 @@ +# Zero-Config OpenAPI + +**Problem**: You want to document your API automatically without writing separate YAML files or complex builder code. +**Solution**: Use RustAPI's native attribute macros and auto-discovery. + +## The "Native" Approach + +Instead of manually mounting routes and defining operations, RustAPI allows you to declare routes directly on your handler functions using attributes. + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::{IntoParams, ToSchema}; + +#[derive(Serialize, Deserialize, ToSchema)] +struct User { + id: i32, + username: String, +} + +#[derive(Deserialize, IntoParams)] +struct SearchParams { + q: String, + page: Option, +} + +// 1. Decorate your handlers +#[rustapi::get("/users")] +async fn list_users(Query(params): Query) -> Json> { + // ... +} + +#[rustapi::post("/users")] +async fn create_user(Json(user): Json) -> Created { + Created(user) +} + +#[tokio::main] +async fn main() -> Result<()> { + // 2. Use RustApi::auto() to automatically find and register all decorated routes + RustApi::auto() + .run("127.0.0.1:8080") + .await +} +``` + +## How It Works + +1. **Macros**: The `#[rustapi::get]`, `#[rustapi::post]`, etc., macros generate a distributed inventory of routes at compile time. +2. **Auto-Discovery**: `RustApi::auto()` collects these inventory items. +3. **Schema Inference**: + * **Request Body**: Inferred from `Json` arguments (requires `T: ToSchema`). + * **Query Params**: Inferred from `Query` arguments (requires `T: IntoParams`). + * **Path Params**: Inferred from `Path` and the URL path (e.g., `/users/{id}`). + * **Responses**: Inferred from the return type. + +## Advanced Usage + +### Customizing Metadata + +You can override or enhance the generated OpenAPI spec using specific attributes: + +```rust +#[rustapi::get("/items/{id}")] +#[rustapi::tag("Inventory")] +#[rustapi::summary("Find a specific item")] +#[rustapi::description("Detailed description supported here.")] +#[rustapi::response(404, description = "Item not found")] +async fn get_item(Path(id): Path) -> Result> { + // ... +} +``` + +### Path Parameter Types + +RustAPI tries to guess types from variable names (e.g., `id` -> integer), but you can be explicit: + +```rust +#[rustapi::get("/users/{uuid}")] +#[rustapi::param(uuid, schema = "uuid")] // Force UUID format +async fn get_user(Path(uuid): Path) -> Json { + // ... +} +``` + +## Discussion + +This approach (often called "Code First") keeps your documentation in sync with your implementation. If you change a struct field, the documentation updates automatically. If you remove a handler, the endpoint disappears from the docs. + +The `RustApi::auto()` function is the key enabler here. It scans the binary for the inventory records created by the macros. This means you don't even need to `mod` or `use` your handler modules in `main.rs` if they are in the same crate! (Though in Rust, modules usually need to be reachable to be compiled).