From 46cc136b1871e075d66c2ba477dfae2ba61ad1ff Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 25 Jan 2026 20:22:39 +0900 Subject: [PATCH 1/4] Add third project --- Cargo.lock | 19 +- examples/axum-example/Cargo.toml | 2 + examples/axum-example/src/lib.rs | 1 + examples/third/Cargo.lock | 1591 ++++++++++++++++++++++++++++++ examples/third/Cargo.toml | 19 + examples/third/openapi.json | 140 +++ examples/third/src/lib.rs | 28 + examples/third/src/routes/mod.rs | 33 + 8 files changed, 1830 insertions(+), 3 deletions(-) create mode 100644 examples/third/Cargo.lock create mode 100644 examples/third/Cargo.toml create mode 100644 examples/third/openapi.json create mode 100644 examples/third/src/lib.rs create mode 100644 examples/third/src/routes/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b74e392..79f320a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,7 @@ dependencies = [ "insta", "serde", "serde_json", + "third", "tokio", "vespera", ] @@ -1423,6 +1424,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "third" +version = "0.1.0" +dependencies = [ + "axum-test", + "insta", + "serde", + "serde_json", + "tokio", + "vespera", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1674,7 +1687,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.21" +version = "0.1.22" dependencies = [ "axum", "axum-extra", @@ -1684,7 +1697,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.21" +version = "0.1.22" dependencies = [ "rstest", "serde", @@ -1693,7 +1706,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "insta", diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index bee54bc..96e633d 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -10,6 +10,8 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +third = { path = "../third" } + [build-dependencies] vespera = { path = "../../crates/vespera" } diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index 950dd7b..b2d48aa 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -25,4 +25,5 @@ pub fn create_app() -> axum::Router { .with_state(Arc::new(AppState { config: "test".to_string(), })) + .merge(third::create_app()) } diff --git a/examples/third/Cargo.lock b/examples/third/Cargo.lock new file mode 100644 index 0000000..eeace9b --- /dev/null +++ b/examples/third/Cargo.lock @@ -0,0 +1,1591 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-example" +version = "0.1.0" +dependencies = [ + "axum", + "axum-test", + "serde", + "serde_json", + "tokio", + "vespera", +] + +[[package]] +name = "axum-test" +version = "18.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bytesize" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" + +[[package]] +name = "cc" +version = "1.2.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "expect-json" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand", + "thiserror", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vespera" +version = "0.1.0" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", + "vespera_core", +] + +[[package]] +name = "vespera_core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/examples/third/Cargo.toml b/examples/third/Cargo.toml new file mode 100644 index 0000000..cf6fa8e --- /dev/null +++ b/examples/third/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "third" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +vespera = { path = "../../crates/vespera" } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +vespera = { path = "../../crates/vespera" } + +[dev-dependencies] +axum-test = "18.7" +insta = "1.46" + diff --git a/examples/third/openapi.json b/examples/third/openapi.json new file mode 100644 index 0000000..5121f69 --- /dev/null +++ b/examples/third/openapi.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API", + "version": "0.1.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/": { + "get": { + "operationId": "root_endpoint", + "description": "Health check endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/hello": { + "get": { + "operationId": "mod_file_endpoint", + "tags": [ + "hello" + ], + "description": "Hello!!", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/map-query": { + "get": { + "operationId": "mod_file_with_map_query", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "TestStruct": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "age" + ] + }, + "ThirdMapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + } + } + }, + "tags": [ + { + "name": "hello" + } + ] +} \ No newline at end of file diff --git a/examples/third/src/lib.rs b/examples/third/src/lib.rs new file mode 100644 index 0000000..069937d --- /dev/null +++ b/examples/third/src/lib.rs @@ -0,0 +1,28 @@ +mod routes; + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use vespera::{Schema, axum, vespera}; + +pub struct AppState { + pub config: String, +} + +#[derive(Serialize, Deserialize, Schema)] +pub struct TestStruct { + pub name: String, + pub age: u32, +} + +/// Create the application router for testing +pub fn create_app() -> axum::Router { + vespera!( + openapi = ["examples/third/openapi.json"], + docs_url = "/docs", + redoc_url = "/redoc" + ) + .with_state(Arc::new(AppState { + config: "test".to_string(), + })) +} diff --git a/examples/third/src/routes/mod.rs b/examples/third/src/routes/mod.rs new file mode 100644 index 0000000..639a69a --- /dev/null +++ b/examples/third/src/routes/mod.rs @@ -0,0 +1,33 @@ + +use serde::Deserialize; +use vespera::{ + Schema, + axum::extract::Query, +}; + + +/// Health check endpoint +#[vespera::route(get)] +pub async fn root_endpoint() -> &'static str { + "root endpoint" +} + +/// Hello!! +#[vespera::route(get, path = "/hello", tags = ["hello"])] +pub async fn mod_file_endpoint() -> &'static str { + "mod file endpoint" +} + +#[derive(Deserialize, Schema, Debug)] +pub struct ThirdMapQuery { + pub name: String, + pub age: u32, + pub optional_age: Option, +} +#[vespera::route(get, path = "/map-query")] +pub async fn mod_file_with_map_query(Query(query): Query) -> &'static str { + println!("map query: {:?}", query.age); + println!("map query: {:?}", query.name); + println!("map query: {:?}", query.optional_age); + "mod file endpoint" +} \ No newline at end of file From d2d4319412ab11c955fa61a00ef0aeeea208a772 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 25 Jan 2026 22:55:25 +0900 Subject: [PATCH 2/4] Impl Export App --- .../changepack_log_dN7zCG1siUU55ptEo9GMn.json | 1 + Cargo.lock | 3 + README.md | 53 ++- SKILL.md | 58 +++ crates/vespera/Cargo.toml | 3 + crates/vespera/src/lib.rs | 76 +++- crates/vespera_core/src/openapi.rs | 60 +++ crates/vespera_macro/src/lib.rs | 429 ++++++++++++++---- examples/axum-example/openapi.json | 108 +++++ examples/axum-example/src/lib.rs | 5 +- .../snapshots/integration_test__openapi.snap | 108 +++++ examples/third/openapi.json | 140 ------ examples/third/src/lib.rs | 22 +- examples/third/src/routes/mod.rs | 31 +- openapi.json | 108 +++++ 15 files changed, 944 insertions(+), 261 deletions(-) create mode 100644 .changepacks/changepack_log_dN7zCG1siUU55ptEo9GMn.json delete mode 100644 examples/third/openapi.json diff --git a/.changepacks/changepack_log_dN7zCG1siUU55ptEo9GMn.json b/.changepacks/changepack_log_dN7zCG1siUU55ptEo9GMn.json new file mode 100644 index 0000000..898bcbb --- /dev/null +++ b/.changepacks/changepack_log_dN7zCG1siUU55ptEo9GMn.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Implement export app","date":"2026-01-25T13:55:16.361701200Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 79f320a..347838d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1691,6 +1691,9 @@ version = "0.1.22" dependencies = [ "axum", "axum-extra", + "serde_json", + "tower-layer", + "tower-service", "vespera_core", "vespera_macro", ] diff --git a/README.md b/README.md index 5ba868c..4c2faf3 100644 --- a/README.md +++ b/README.md @@ -194,10 +194,27 @@ let app = vespera!( servers = [ // OpenAPI servers { url = "https://api.example.com", description = "Production" }, { url = "http://localhost:3000", description = "Development" } - ] + ], + merge = [crate1::App1, crate2::App2] // Merge child vespera apps ); ``` +## `export_app!` Macro Reference + +Export a vespera app for merging into other apps: + +```rust +// Basic usage (scans "routes" folder by default) +vespera::export_app!(MyApp); + +// Custom directory +vespera::export_app!(MyApp, dir = "api"); +``` + +Generates a struct with: +- `MyApp::OPENAPI_SPEC: &'static str` - The OpenAPI JSON spec +- `MyApp::router() -> Router` - Function returning the Axum router + ### Environment Variable Fallbacks All parameters support environment variable fallbacks: @@ -251,6 +268,40 @@ let app = vespera!("api"); let app = vespera!(dir = "api"); ``` +### Merging Multiple Vespera Apps + +Combine routes and OpenAPI specs from multiple vespera apps at compile time: + +**Child app (e.g., `third` crate):** +```rust +// src/lib.rs +mod routes; + +// Export app for merging (dir defaults to "routes") +vespera::export_app!(ThirdApp); + +// Or with custom directory +// vespera::export_app!(ThirdApp, dir = "api"); +``` + +**Parent app:** +```rust +// src/main.rs +use vespera::vespera; + +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [third::ThirdApp] // Merges router AND OpenAPI spec +) +.with_state(app_state); +``` + +This automatically: +- Merges all routes from child apps into the parent router +- Combines OpenAPI specs (paths, schemas, tags) into a single spec +- Makes Swagger UI show all routes from all apps + --- ## Type Mapping diff --git a/SKILL.md b/SKILL.md index 6e12e46..8ef7d58 100644 --- a/SKILL.md +++ b/SKILL.md @@ -159,3 +159,61 @@ npx @apidevtools/swagger-cli validate openapi.json | `VESPERA_DOCS_URL` | Swagger UI path | none | | `VESPERA_REDOC_URL` | ReDoc path | none | | `VESPERA_SERVER_URL` | Server URL | `http://localhost:3000` | + +--- + +## Merging Multiple Vespera Apps + +Combine routes and OpenAPI specs from multiple apps at compile time. + +### export_app! Macro + +Export an app for merging: + +```rust +// Child crate (e.g., third/src/lib.rs) +mod routes; + +// Basic - scans "routes" folder by default +vespera::export_app!(ThirdApp); + +// Custom directory +vespera::export_app!(ThirdApp, dir = "api"); +``` + +Generates: +- `ThirdApp::OPENAPI_SPEC: &'static str` - OpenAPI JSON +- `ThirdApp::router() -> Router` - Axum router + +### merge Parameter + +Merge child apps in parent: + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [third::ThirdApp, other::OtherApp] +) +.with_state(state); +``` + +**What happens:** +1. Child routers merged into parent router +2. OpenAPI specs merged (paths, schemas, tags) +3. Swagger UI shows all routes + +### How It Works (Compile-Time) + +``` +Child compilation (export_app!): + 1. Scan routes/ folder + 2. Generate OpenAPI spec + 3. Write to target/vespera/{Name}.openapi.json + +Parent compilation (vespera! with merge): + 1. Generate parent OpenAPI spec + 2. Read child specs from target/vespera/ + 3. Merge all specs together + 4. Write merged openapi.json +``` diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index b5476ac..716ad8d 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -15,3 +15,6 @@ vespera_core = { workspace = true } vespera_macro = { workspace = true } axum = "0.8" axum-extra = { version = "0.12", optional = true } +serde_json = "1" +tower-layer = "0.3" +tower-service = "0.3" diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index cbbeb28..773c014 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -16,8 +16,14 @@ pub mod openapi { pub use vespera_core::openapi::*; } +// Re-export OpenApi directly for convenience (used by merge feature) +pub use vespera_core::openapi::OpenApi; + // Re-export macros from vespera_macro -pub use vespera_macro::{Schema, route, vespera}; +pub use vespera_macro::{export_app, route, vespera, Schema}; + +// Re-export serde_json for merge feature (runtime spec merging) +pub use serde_json; // Re-export axum for convenience pub mod axum { @@ -27,3 +33,71 @@ pub mod axum { pub mod axum_extra { pub use axum_extra::*; } + +/// A router wrapper that defers merging until `with_state()` is called. +/// +/// This is necessary because in Axum, routers can only be merged when they have +/// the same state type. By deferring the merge, we ensure that: +/// 1. The base router's `.with_state()` is called first, converting it to `Router<()>` +/// 2. Then the child routers (also `Router<()>`) are merged +/// +/// This wrapper is returned by `vespera!()` when the `merge` parameter is used. +pub struct VesperaRouter +where + S: Clone + Send + Sync + 'static, +{ + base: axum::Router, + /// Routers to merge after `with_state()` is called + merge_fns: Vec axum::Router<()>>, +} + +impl VesperaRouter +where + S: Clone + Send + Sync + 'static, +{ + /// Create a new VesperaRouter with a base router and routers to merge + pub fn new(base: axum::Router, merge_fns: Vec axum::Router<()>>) -> Self { + Self { base, merge_fns } + } + + /// Provide the state for the router and merge all child routers. + /// + /// This is equivalent to calling `Router::with_state()` and then merging + /// all the child routers. + /// + /// After calling `with_state()`, the router's state type becomes `()` because + /// the state has been provided. Child routers (also `Router<()>`) can then be merged. + pub fn with_state(self, state: S) -> axum::Router<()> { + // First, apply the state to convert Router to Router<()> + let mut router: axum::Router<()> = self.base.with_state(state); + + // Then merge all child routers (they are Router<()> which can be merged + // into Router<()> without issues) + for merge_fn in self.merge_fns { + router = router.merge(merge_fn()); + } + + router + } + + /// Add a layer to the router. + pub fn layer(self, layer: L) -> Self + where + L: tower_layer::Layer + Clone + Send + Sync + 'static, + L::Service: tower_service::Service + Clone + Send + Sync + 'static, + >::Response: + axum::response::IntoResponse + 'static, + >::Error: + Into + 'static, + >::Future: Send + 'static, + { + Self { + base: self.base.layer(layer), + merge_fns: self.merge_fns, + } + } +} + +// Re-export tower_layer and tower_service for the layer method +pub use tower_layer; +pub use tower_service; diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index 532d0e3..cb560df 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -140,3 +140,63 @@ pub struct OpenApi { #[serde(skip_serializing_if = "Option::is_none")] pub external_docs: Option, } + +impl OpenApi { + /// Merge another OpenAPI document into this one. + /// Paths, schemas, and tags from `other` are added to `self`. + /// If there are conflicts, `self` takes precedence. + pub fn merge(&mut self, other: OpenApi) { + // Merge paths (self takes precedence on conflict) + for (path, item) in other.paths { + self.paths.entry(path).or_insert(item); + } + + // Merge components + if let Some(other_components) = other.components { + let self_components = self.components.get_or_insert_with(|| Components { + schemas: None, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + // Merge schemas + if let Some(other_schemas) = other_components.schemas { + let self_schemas = self_components.schemas.get_or_insert_with(BTreeMap::new); + for (name, schema) in other_schemas { + self_schemas.entry(name).or_insert(schema); + } + } + + // Merge security schemes + if let Some(other_security_schemes) = other_components.security_schemes { + let self_security_schemes = self_components + .security_schemes + .get_or_insert_with(HashMap::new); + for (name, scheme) in other_security_schemes { + self_security_schemes.entry(name).or_insert(scheme); + } + } + } + + // Merge tags (deduplicate by name) + if let Some(other_tags) = other.tags { + let self_tags = self.tags.get_or_insert_with(Vec::new); + for tag in other_tags { + if !self_tags.iter().any(|t| t.name == tag.name) { + self_tags.push(tag); + } + } + } + } + + /// Merge from a JSON string. Returns error if parsing fails. + pub fn merge_from_str(&mut self, json_str: &str) -> Result<(), serde_json::Error> { + let other: OpenApi = serde_json::from_str(json_str)?; + self.merge(other); + Ok(()) + } +} diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 6d43984..edc3250 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -104,6 +104,8 @@ struct AutoRouterInput { docs_url: Option, redoc_url: Option, servers: Option>, + /// Apps to merge (e.g., [third::ThirdApp, another::AnotherApp]) + merge: Option>, } impl Parse for AutoRouterInput { @@ -115,6 +117,7 @@ impl Parse for AutoRouterInput { let mut docs_url = None; let mut redoc_url = None; let mut servers = None; + let mut merge = None; while !input.is_empty() { let lookahead = input.lookahead1(); @@ -150,11 +153,14 @@ impl Parse for AutoRouterInput { "servers" => { servers = Some(parse_servers_values(input)?); } + "merge" => { + merge = Some(parse_merge_values(input)?); + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown field: `{}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, or `servers`", + "unknown field: `{}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`", ident_str ), )); @@ -222,10 +228,22 @@ impl Parse for AutoRouterInput { }] }) }), + merge, }) } } +/// Parse merge values: merge = [path::to::App, another::App] +fn parse_merge_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let paths: Punctuated = + content.parse_terminated(syn::Path::parse, syn::Token![,])?; + Ok(paths.into_iter().collect()) +} + fn parse_openapi_values(input: ParseStream) -> syn::Result> { input.parse::()?; @@ -390,6 +408,8 @@ struct ProcessedVesperaInput { docs_url: Option, redoc_url: Option, servers: Option>, + /// Apps to merge (syn::Path for code generation) + merge: Vec, } /// Process AutoRouterInput into extracted values @@ -418,6 +438,7 @@ fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { }) .collect() }), + merge: input.merge.unwrap_or_default(), } } @@ -431,13 +452,40 @@ fn generate_and_write_openapi( return Ok((None, None)); } - let json_str = serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( + let mut openapi_doc = generate_openapi_doc_with_metadata( input.title.clone(), input.version.clone(), input.servers.clone(), metadata, - )) - .map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?; + ); + + // Merge specs from child apps at compile time + if !input.merge.is_empty() { + if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let manifest_path = Path::new(&manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + + for merge_path in &input.merge { + // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") + if let Some(last_segment) = merge_path.segments.last() { + let struct_name = last_segment.ident.to_string(); + let spec_file = vespera_dir.join(format!("{}.openapi.json", struct_name)); + + if let Ok(spec_content) = std::fs::read_to_string(&spec_file) { + if let Ok(child_spec) = + serde_json::from_str::(&spec_content) + { + openapi_doc.merge(child_spec); + } + } + } + } + } + } + + let json_str = serde_json::to_string_pretty(&openapi_doc) + .map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?; for openapi_file_name in &input.openapi_file_names { let file_path = Path::new(openapi_file_name); @@ -501,7 +549,7 @@ pub fn vespera(input: TokenStream) -> TokenStream { } }; - generate_router_code(&metadata, docs_info, redoc_info).into() + generate_router_code(&metadata, docs_info, redoc_info, &processed.merge).into() } fn find_folder_path(folder_name: &str) -> std::path::PathBuf { @@ -515,10 +563,45 @@ fn find_folder_path(folder_name: &str) -> std::path::PathBuf { Path::new(folder_name).to_path_buf() } +/// Find the workspace root's target directory +fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { + // Look for workspace root by finding a Cargo.toml with [workspace] section + let mut current = Some(manifest_path); + let mut last_with_lock = None; + + while let Some(dir) = current { + // Check if this directory has Cargo.lock + if dir.join("Cargo.lock").exists() { + last_with_lock = Some(dir.to_path_buf()); + } + + // Check if this is a workspace root (has Cargo.toml with [workspace]) + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() { + if let Ok(contents) = std::fs::read_to_string(&cargo_toml) { + if contents.contains("[workspace]") { + return dir.join("target"); + } + } + } + + current = dir.parent(); + } + + // If we found a Cargo.lock but no [workspace], use the topmost one + if let Some(lock_dir) = last_with_lock { + return lock_dir.join("target"); + } + + // Fallback: use manifest dir's target + manifest_path.join("target") +} + fn generate_router_code( metadata: &CollectedMetadata, docs_info: Option<(String, String)>, redoc_info: Option<(String, String)>, + merge_apps: &[syn::Path], ) -> proc_macro2::TokenStream { let mut router_nests = Vec::new(); @@ -556,94 +639,262 @@ fn generate_router_code( )); } + // Check if we need to merge specs at runtime + let has_merge = !merge_apps.is_empty(); + if let Some((docs_url, spec)) = docs_info { let method_path = http_method_to_token_stream(HttpMethod::Get); - let html = format!( - r#" - - - - - Swagger UI - - - -
- - - - - - - - -"#, - spec_json = spec - ) - .replace("\n", ""); - - router_nests.push(quote!( - .route(#docs_url, #method_path(|| async { vespera::axum::response::Html(#html) })) - )); + if has_merge { + // Generate code that merges specs at runtime using OnceLock + let merge_spec_code: Vec<_> = merge_apps + .iter() + .map(|app_path| { + quote! { + if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { + merged.merge(other); + } + } + }) + .collect(); + + router_nests.push(quote!( + .route(#docs_url, #method_path(|| async { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let spec = MERGED_SPEC.get_or_init(|| { + let base_spec = #spec; + let mut merged: vespera::OpenApi = vespera::serde_json::from_str(base_spec).unwrap(); + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged).unwrap() + }); + let html = format!( + r#"Swagger UI
"#, + spec + ); + vespera::axum::response::Html(html) + })) + )); + } else { + let html = format!( + r#"Swagger UI
"#, + spec_json = spec + ); + + router_nests.push(quote!( + .route(#docs_url, #method_path(|| async { vespera::axum::response::Html(#html) })) + )); + } } if let Some((redoc_url, spec)) = redoc_info { let method_path = http_method_to_token_stream(HttpMethod::Get); - let html = format!( - r#" - - - - - ReDoc - - - - - -
- - - - -"#, - spec_json = spec - ) - .replace("\n", ""); + if has_merge { + // Generate code that merges specs at runtime using OnceLock + let merge_spec_code: Vec<_> = merge_apps + .iter() + .map(|app_path| { + quote! { + if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { + merged.merge(other); + } + } + }) + .collect(); + + router_nests.push(quote!( + .route(#redoc_url, #method_path(|| async { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let spec = MERGED_SPEC.get_or_init(|| { + let base_spec = #spec; + let mut merged: vespera::OpenApi = vespera::serde_json::from_str(base_spec).unwrap(); + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged).unwrap() + }); + let html = format!( + r#"ReDoc
"#, + spec + ); + vespera::axum::response::Html(html) + })) + )); + } else { + let html = format!( + r#"ReDoc
"#, + spec_json = spec + ); + + router_nests.push(quote!( + .route(#redoc_url, #method_path(|| async { vespera::axum::response::Html(#html) })) + )); + } + } - router_nests.push(quote!( - .route(#redoc_url, #method_path(|| async { vespera::axum::response::Html(#html) })) - )); + if merge_apps.is_empty() { + quote! { + vespera::axum::Router::new() + #( #router_nests )* + } + } else { + // When merging apps, return VesperaRouter which defers the merge + // until with_state() is called. This is necessary because Axum requires + // merged routers to have the same state type. + quote! { + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } +} + +/// Input for export_app! macro +struct ExportAppInput { + /// App name (struct name to generate) + name: syn::Ident, + /// Route directory + dir: Option, +} + +impl Parse for ExportAppInput { + fn parse(input: ParseStream) -> syn::Result { + let name: syn::Ident = input.parse()?; + + let mut dir = None; + + // Parse optional comma and arguments + while input.peek(syn::Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "dir" => { + input.parse::()?; + dir = Some(input.parse()?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown field: `{}`. Expected `dir`", ident_str), + )); + } + } + } + + Ok(ExportAppInput { name, dir }) + } +} + +/// Export a vespera app as a reusable component. +/// +/// Generates a struct with: +/// - `OPENAPI_SPEC: &'static str` - The OpenAPI JSON spec +/// - `router() -> Router` - Function returning the Axum router +/// +/// # Example +/// ```ignore +/// // Simple - uses "routes" folder by default +/// vespera::export_app!(MyApp); +/// +/// // Custom directory +/// vespera::export_app!(MyApp, dir = "api"); +/// +/// // Generates: +/// // pub struct MyApp; +/// // impl MyApp { +/// // pub const OPENAPI_SPEC: &'static str = "..."; +/// // pub fn router() -> axum::Router { ... } +/// // } +/// ``` +#[proc_macro] +pub fn export_app(input: TokenStream) -> TokenStream { + let ExportAppInput { name, dir } = syn::parse_macro_input!(input as ExportAppInput); + + let folder_name = dir + .map(|d| d.value()) + .or_else(|| std::env::var("VESPERA_DIR").ok()) + .unwrap_or_else(|| "routes".to_string()); + + let folder_path = find_folder_path(&folder_name); + if !folder_path.exists() { + return syn::Error::new( + Span::call_site(), + format!("Folder not found: {}", folder_name), + ) + .to_compile_error() + .into(); } + let mut metadata = match collect_metadata(&folder_path, &folder_name) { + Ok(m) => m, + Err(e) => { + return syn::Error::new( + Span::call_site(), + format!("Failed to collect metadata: {}", e), + ) + .to_compile_error() + .into(); + } + }; + metadata + .structs + .extend(SCHEMA_STORAGE.lock().unwrap().clone()); + + // Generate OpenAPI spec JSON string + let openapi_doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let spec_json = match serde_json::to_string(&openapi_doc) { + Ok(json) => json, + Err(e) => { + return syn::Error::new( + Span::call_site(), + format!("Failed to serialize OpenAPI spec: {}", e), + ) + .to_compile_error() + .into(); + } + }; + + // Write spec to temp file for compile-time merging by parent apps + // The file is written to target/vespera/{StructName}.openapi.json + let name_str = name.to_string(); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR not set"); + // Find target directory (go up from manifest dir to workspace root if needed) + let manifest_path = Path::new(&manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + std::fs::create_dir_all(&vespera_dir) + .unwrap_or_else(|e| panic!("Failed to create vespera dir {:?}: {}", vespera_dir, e)); + let spec_file = vespera_dir.join(format!("{}.openapi.json", name_str)); + std::fs::write(&spec_file, &spec_json) + .unwrap_or_else(|e| panic!("Failed to write spec file {:?}: {}", spec_file, e)); + + // Generate router code (without docs routes, no merge) + let router_code = generate_router_code(&metadata, None, None, &[]); + quote! { - vespera::axum::Router::new() - #( #router_nests )* + /// Auto-generated vespera app struct + pub struct #name; + + impl #name { + /// OpenAPI specification as JSON string + pub const OPENAPI_SPEC: &'static str = #spec_json; + + /// Create the router for this app. + /// Returns `Router<()>` which can be merged into any other router. + pub fn router() -> vespera::axum::Router<()> { + #router_code + } + } } + .into() } #[cfg(test)] @@ -671,6 +922,7 @@ mod tests { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -828,6 +1080,7 @@ pub fn get_users() -> String { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -913,6 +1166,7 @@ pub fn update_user() -> String { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -967,6 +1221,7 @@ pub fn create_users() -> String { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -1013,6 +1268,7 @@ pub fn index() -> String { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -1049,6 +1305,7 @@ pub fn get_users() -> String { &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, + &[], ); let code = result.to_string(); @@ -1246,7 +1503,7 @@ pub fn get_users() -> String { let metadata = CollectedMetadata::new(); let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); - let result = generate_router_code(&metadata, docs_info, None); + let result = generate_router_code(&metadata, docs_info, None, &[]); let code = result.to_string(); assert!(code.contains("/docs")); @@ -1258,7 +1515,7 @@ pub fn get_users() -> String { let metadata = CollectedMetadata::new(); let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); - let result = generate_router_code(&metadata, None, redoc_info); + let result = generate_router_code(&metadata, None, redoc_info, &[]); let code = result.to_string(); assert!(code.contains("/redoc")); @@ -1271,7 +1528,7 @@ pub fn get_users() -> String { let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); - let result = generate_router_code(&metadata, docs_info, redoc_info); + let result = generate_router_code(&metadata, docs_info, redoc_info, &[]); let code = result.to_string(); assert!(code.contains("/docs")); @@ -1510,6 +1767,7 @@ pub fn get_users() -> String { docs_url: None, redoc_url: None, servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); @@ -1529,6 +1787,7 @@ pub fn get_users() -> String { docs_url: Some("/docs".to_string()), redoc_url: None, servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); @@ -1552,6 +1811,7 @@ pub fn get_users() -> String { docs_url: None, redoc_url: Some("/redoc".to_string()), servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); @@ -1573,6 +1833,7 @@ pub fn get_users() -> String { docs_url: Some("/docs".to_string()), redoc_url: Some("/redoc".to_string()), servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); @@ -1595,6 +1856,7 @@ pub fn get_users() -> String { docs_url: None, redoc_url: None, servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); @@ -1621,6 +1883,7 @@ pub fn get_users() -> String { docs_url: None, redoc_url: None, servers: None, + merge: vec![], }; let metadata = CollectedMetadata::new(); let result = generate_and_write_openapi(&processed, &metadata); diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index aec3f80..00ba569 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -834,6 +834,92 @@ } } }, + "/third": { + "get": { + "operationId": "third_root_endpoint", + "description": "Third app root endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/hello": { + "get": { + "operationId": "third_hello_endpoint", + "tags": [ + "third" + ], + "description": "Third app hello endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/map-query": { + "get": { + "operationId": "third_map_query", + "tags": [ + "third" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1692,6 +1778,25 @@ "age" ] }, + "ThirdMapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + }, "User": { "type": "object", "properties": { @@ -1716,6 +1821,9 @@ "tags": [ { "name": "hello" + }, + { + "name": "third" } ] } \ No newline at end of file diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index b2d48aa..e9a30b8 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use vespera::{Schema, axum, vespera}; +use third::ThirdApp; pub struct AppState { pub config: String, @@ -20,10 +21,10 @@ pub fn create_app() -> axum::Router { vespera!( openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", - redoc_url = "/redoc" + redoc_url = "/redoc", + merge = [ThirdApp] ) .with_state(Arc::new(AppState { config: "test".to_string(), })) - .merge(third::create_app()) } diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 819d8db..6387b0f 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -838,6 +838,92 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/third": { + "get": { + "operationId": "third_root_endpoint", + "description": "Third app root endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/hello": { + "get": { + "operationId": "third_hello_endpoint", + "tags": [ + "third" + ], + "description": "Third app hello endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/map-query": { + "get": { + "operationId": "third_map_query", + "tags": [ + "third" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1696,6 +1782,25 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "age" ] }, + "ThirdMapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + }, "User": { "type": "object", "properties": { @@ -1720,6 +1825,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "tags": [ { "name": "hello" + }, + { + "name": "third" } ] } diff --git a/examples/third/openapi.json b/examples/third/openapi.json deleted file mode 100644 index 5121f69..0000000 --- a/examples/third/openapi.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "API", - "version": "0.1.0" - }, - "servers": [ - { - "url": "http://localhost:3000" - } - ], - "paths": { - "/": { - "get": { - "operationId": "root_endpoint", - "description": "Health check endpoint", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/hello": { - "get": { - "operationId": "mod_file_endpoint", - "tags": [ - "hello" - ], - "description": "Hello!!", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/map-query": { - "get": { - "operationId": "mod_file_with_map_query", - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "name": "optional_age", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "TestStruct": { - "type": "object", - "properties": { - "age": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "age" - ] - }, - "ThirdMapQuery": { - "type": "object", - "properties": { - "age": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "optional_age": { - "type": "integer", - "nullable": true - } - }, - "required": [ - "name", - "age" - ] - } - } - }, - "tags": [ - { - "name": "hello" - } - ] -} \ No newline at end of file diff --git a/examples/third/src/lib.rs b/examples/third/src/lib.rs index 069937d..8a3e484 100644 --- a/examples/third/src/lib.rs +++ b/examples/third/src/lib.rs @@ -1,13 +1,7 @@ mod routes; -use std::sync::Arc; - use serde::{Deserialize, Serialize}; -use vespera::{Schema, axum, vespera}; - -pub struct AppState { - pub config: String, -} +use vespera::Schema; #[derive(Serialize, Deserialize, Schema)] pub struct TestStruct { @@ -15,14 +9,6 @@ pub struct TestStruct { pub age: u32, } -/// Create the application router for testing -pub fn create_app() -> axum::Router { - vespera!( - openapi = ["examples/third/openapi.json"], - docs_url = "/docs", - redoc_url = "/redoc" - ) - .with_state(Arc::new(AppState { - config: "test".to_string(), - })) -} +// Export the app for merging by other vespera apps +// dir defaults to "routes" when omitted +vespera::export_app!(ThirdApp); diff --git a/examples/third/src/routes/mod.rs b/examples/third/src/routes/mod.rs index 639a69a..997870d 100644 --- a/examples/third/src/routes/mod.rs +++ b/examples/third/src/routes/mod.rs @@ -1,21 +1,19 @@ - use serde::Deserialize; use vespera::{ Schema, axum::extract::Query, }; - -/// Health check endpoint -#[vespera::route(get)] -pub async fn root_endpoint() -> &'static str { - "root endpoint" +/// Third app root endpoint +#[vespera::route(get, path = "/third")] +pub async fn third_root_endpoint() -> &'static str { + "third app root endpoint" } -/// Hello!! -#[vespera::route(get, path = "/hello", tags = ["hello"])] -pub async fn mod_file_endpoint() -> &'static str { - "mod file endpoint" +/// Third app hello endpoint +#[vespera::route(get, path = "/third/hello", tags = ["third"])] +pub async fn third_hello_endpoint() -> &'static str { + "third app hello endpoint" } #[derive(Deserialize, Schema, Debug)] @@ -24,10 +22,11 @@ pub struct ThirdMapQuery { pub age: u32, pub optional_age: Option, } -#[vespera::route(get, path = "/map-query")] -pub async fn mod_file_with_map_query(Query(query): Query) -> &'static str { - println!("map query: {:?}", query.age); - println!("map query: {:?}", query.name); - println!("map query: {:?}", query.optional_age); - "mod file endpoint" + +#[vespera::route(get, path = "/third/map-query", tags = ["third"])] +pub async fn third_map_query(Query(query): Query) -> &'static str { + println!("third map query: {:?}", query.age); + println!("third map query: {:?}", query.name); + println!("third map query: {:?}", query.optional_age); + "third app map query endpoint" } \ No newline at end of file diff --git a/openapi.json b/openapi.json index aec3f80..00ba569 100644 --- a/openapi.json +++ b/openapi.json @@ -834,6 +834,92 @@ } } }, + "/third": { + "get": { + "operationId": "third_root_endpoint", + "description": "Third app root endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/hello": { + "get": { + "operationId": "third_hello_endpoint", + "tags": [ + "third" + ], + "description": "Third app hello endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/third/map-query": { + "get": { + "operationId": "third_map_query", + "tags": [ + "third" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1692,6 +1778,25 @@ "age" ] }, + "ThirdMapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + }, "User": { "type": "object", "properties": { @@ -1716,6 +1821,9 @@ "tags": [ { "name": "hello" + }, + { + "name": "third" } ] } \ No newline at end of file From 86d8fd23a2152452d77a96a2eecc0f85f970845b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 25 Jan 2026 22:58:09 +0900 Subject: [PATCH 3/4] Fix format --- crates/vespera/src/lib.rs | 2 +- crates/vespera_core/src/openapi.rs | 2 +- crates/vespera_macro/src/lib.rs | 21 ++++++++------------- examples/axum-example/src/lib.rs | 2 +- examples/third/src/routes/mod.rs | 7 ++----- 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index 773c014..ecbebae 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -20,7 +20,7 @@ pub mod openapi { pub use vespera_core::openapi::OpenApi; // Re-export macros from vespera_macro -pub use vespera_macro::{export_app, route, vespera, Schema}; +pub use vespera_macro::{Schema, export_app, route, vespera}; // Re-export serde_json for merge feature (runtime spec merging) pub use serde_json; diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index cb560df..b8b9964 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -153,7 +153,7 @@ impl OpenApi { // Merge components if let Some(other_components) = other.components { - let self_components = self.components.get_or_insert_with(|| Components { + let self_components = self.components.get_or_insert(Components { schemas: None, responses: None, parameters: None, diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index edc3250..aafe419 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -460,8 +460,8 @@ fn generate_and_write_openapi( ); // Merge specs from child apps at compile time - if !input.merge.is_empty() { - if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + if !input.merge.is_empty() + && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { let manifest_path = Path::new(&manifest_dir); let target_dir = find_target_dir(manifest_path); let vespera_dir = target_dir.join("vespera"); @@ -472,17 +472,15 @@ fn generate_and_write_openapi( let struct_name = last_segment.ident.to_string(); let spec_file = vespera_dir.join(format!("{}.openapi.json", struct_name)); - if let Ok(spec_content) = std::fs::read_to_string(&spec_file) { - if let Ok(child_spec) = + if let Ok(spec_content) = std::fs::read_to_string(&spec_file) + && let Ok(child_spec) = serde_json::from_str::(&spec_content) { openapi_doc.merge(child_spec); } - } } } } - } let json_str = serde_json::to_string_pretty(&openapi_doc) .map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?; @@ -577,13 +575,11 @@ fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { // Check if this is a workspace root (has Cargo.toml with [workspace]) let cargo_toml = dir.join("Cargo.toml"); - if cargo_toml.exists() { - if let Ok(contents) = std::fs::read_to_string(&cargo_toml) { - if contents.contains("[workspace]") { + if cargo_toml.exists() + && let Ok(contents) = std::fs::read_to_string(&cargo_toml) + && contents.contains("[workspace]") { return dir.join("target"); } - } - } current = dir.parent(); } @@ -864,8 +860,7 @@ pub fn export_app(input: TokenStream) -> TokenStream { // Write spec to temp file for compile-time merging by parent apps // The file is written to target/vespera/{StructName}.openapi.json let name_str = name.to_string(); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR not set"); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); // Find target directory (go up from manifest dir to workspace root if needed) let manifest_path = Path::new(&manifest_dir); let target_dir = find_target_dir(manifest_path); diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index e9a30b8..05e6f34 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -3,8 +3,8 @@ mod routes; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vespera::{Schema, axum, vespera}; use third::ThirdApp; +use vespera::{Schema, axum, vespera}; pub struct AppState { pub config: String, diff --git a/examples/third/src/routes/mod.rs b/examples/third/src/routes/mod.rs index 997870d..0579650 100644 --- a/examples/third/src/routes/mod.rs +++ b/examples/third/src/routes/mod.rs @@ -1,8 +1,5 @@ use serde::Deserialize; -use vespera::{ - Schema, - axum::extract::Query, -}; +use vespera::{Schema, axum::extract::Query}; /// Third app root endpoint #[vespera::route(get, path = "/third")] @@ -29,4 +26,4 @@ pub async fn third_map_query(Query(query): Query) -> &'static str println!("third map query: {:?}", query.name); println!("third map query: {:?}", query.optional_age); "third app map query endpoint" -} \ No newline at end of file +} From d638dd4e0b89d3ad4a0d07342bdef26a1912bb6c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 25 Jan 2026 23:10:11 +0900 Subject: [PATCH 4/4] Add testcase --- Cargo.lock | 15 + crates/vespera_core/src/openapi.rs | 314 ++++++++++++++++++ crates/vespera_macro/src/lib.rs | 42 +-- examples/axum-example/Cargo.toml | 1 + examples/axum-example/src/lib.rs | 21 ++ .../axum-example/tests/integration_test.rs | 107 +++++- 6 files changed, 479 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 347838d..101519c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "serde_json", "third", "tokio", + "tower-http", "vespera", ] @@ -1571,6 +1572,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index b8b9964..2e82cfb 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -200,3 +200,317 @@ impl OpenApi { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::route::{Operation, PathItem}; + use crate::schema::{Components, Schema, SchemaType, SecurityScheme, SecuritySchemeType}; + + fn create_base_openapi() -> OpenApi { + OpenApi { + openapi: OpenApiVersion::V3_1_0, + info: Info { + title: "Base API".to_string(), + version: "1.0.0".to_string(), + description: None, + terms_of_service: None, + contact: None, + license: None, + summary: None, + }, + servers: None, + paths: BTreeMap::new(), + components: None, + security: None, + tags: None, + external_docs: None, + } + } + + fn create_path_item(summary: &str) -> PathItem { + PathItem { + get: Some(Operation { + summary: Some(summary.to_string()), + description: None, + operation_id: None, + tags: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + }), + post: None, + put: None, + delete: None, + patch: None, + options: None, + head: None, + trace: None, + parameters: None, + summary: None, + description: None, + } + } + + #[test] + fn test_merge_paths() { + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Get users")); + + let mut other = create_base_openapi(); + other + .paths + .insert("/posts".to_string(), create_path_item("Get posts")); + other + .paths + .insert("/users".to_string(), create_path_item("Other users")); // Conflict + + base.merge(other); + + // Both paths should exist + assert!(base.paths.contains_key("/users")); + assert!(base.paths.contains_key("/posts")); + // Self takes precedence on conflict + assert_eq!( + base.paths + .get("/users") + .unwrap() + .get + .as_ref() + .unwrap() + .summary, + Some("Get users".to_string()) + ); + } + + #[test] + fn test_merge_schemas() { + let mut base = create_base_openapi(); + let mut base_schemas = BTreeMap::new(); + base_schemas.insert("User".to_string(), Schema::object()); + base.components = Some(Components { + schemas: Some(base_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + let mut other = create_base_openapi(); + let mut other_schemas = BTreeMap::new(); + other_schemas.insert("Post".to_string(), Schema::object()); + other_schemas.insert("User".to_string(), Schema::string()); // Conflict + other.components = Some(Components { + schemas: Some(other_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + base.merge(other); + + let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("User")); + assert!(schemas.contains_key("Post")); + // Self takes precedence on conflict + assert_eq!( + schemas.get("User").unwrap().schema_type, + Some(SchemaType::Object) + ); + } + + #[test] + fn test_merge_schemas_when_self_has_no_components() { + let mut base = create_base_openapi(); + assert!(base.components.is_none()); + + let mut other = create_base_openapi(); + let mut other_schemas = BTreeMap::new(); + other_schemas.insert("Post".to_string(), Schema::object()); + other.components = Some(Components { + schemas: Some(other_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + base.merge(other); + + assert!(base.components.is_some()); + let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Post")); + } + + #[test] + fn test_merge_security_schemes() { + let mut base = create_base_openapi(); + let mut base_security_schemes = HashMap::new(); + base_security_schemes.insert( + "bearerAuth".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: None, + name: None, + r#in: None, + scheme: Some("bearer".to_string()), + bearer_format: Some("JWT".to_string()), + }, + ); + base.components = Some(Components { + schemas: None, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: Some(base_security_schemes), + }); + + let mut other = create_base_openapi(); + let mut other_security_schemes = HashMap::new(); + other_security_schemes.insert( + "apiKey".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::ApiKey, + description: None, + name: Some("X-API-Key".to_string()), + r#in: Some("header".to_string()), + scheme: None, + bearer_format: None, + }, + ); + other.components = Some(Components { + schemas: None, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: Some(other_security_schemes), + }); + + base.merge(other); + + let security_schemes = base + .components + .as_ref() + .unwrap() + .security_schemes + .as_ref() + .unwrap(); + assert!(security_schemes.contains_key("bearerAuth")); + assert!(security_schemes.contains_key("apiKey")); + } + + #[test] + fn test_merge_tags() { + let mut base = create_base_openapi(); + base.tags = Some(vec![Tag { + name: "users".to_string(), + description: Some("User operations".to_string()), + external_docs: None, + }]); + + let mut other = create_base_openapi(); + other.tags = Some(vec![ + Tag { + name: "posts".to_string(), + description: Some("Post operations".to_string()), + external_docs: None, + }, + Tag { + name: "users".to_string(), + description: Some("Duplicate users tag".to_string()), + external_docs: None, + }, // Duplicate + ]); + + base.merge(other); + + let tags = base.tags.as_ref().unwrap(); + assert_eq!(tags.len(), 2); // No duplicates + assert!(tags.iter().any(|t| t.name == "users")); + assert!(tags.iter().any(|t| t.name == "posts")); + // Self's description takes precedence + let users_tag = tags.iter().find(|t| t.name == "users").unwrap(); + assert_eq!(users_tag.description, Some("User operations".to_string())); + } + + #[test] + fn test_merge_tags_when_self_has_none() { + let mut base = create_base_openapi(); + assert!(base.tags.is_none()); + + let mut other = create_base_openapi(); + other.tags = Some(vec![Tag { + name: "posts".to_string(), + description: None, + external_docs: None, + }]); + + base.merge(other); + + assert!(base.tags.is_some()); + assert_eq!(base.tags.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_merge_from_str() { + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Get users")); + + let other_json = r#"{ + "openapi": "3.1.0", + "info": { "title": "Other API", "version": "2.0.0" }, + "paths": { + "/posts": { "get": { "summary": "Get posts", "responses": {} } } + } + }"#; + + let result = base.merge_from_str(other_json); + assert!(result.is_ok()); + assert!(base.paths.contains_key("/users")); + assert!(base.paths.contains_key("/posts")); + } + + #[test] + fn test_merge_from_str_invalid_json() { + let mut base = create_base_openapi(); + let invalid_json = "{ invalid json }"; + + let result = base.merge_from_str(invalid_json); + assert!(result.is_err()); + } + + #[test] + fn test_merge_empty_other() { + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Get users")); + base.tags = Some(vec![Tag { + name: "users".to_string(), + description: None, + external_docs: None, + }]); + + let other = create_base_openapi(); // Empty paths, no components, no tags + + base.merge(other); + + // Base should remain unchanged + assert_eq!(base.paths.len(), 1); + assert!(base.paths.contains_key("/users")); + assert_eq!(base.tags.as_ref().unwrap().len(), 1); + } +} diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index aafe419..83f543e 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -461,26 +461,27 @@ fn generate_and_write_openapi( // Merge specs from child apps at compile time if !input.merge.is_empty() - && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { - let manifest_path = Path::new(&manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - - for merge_path in &input.merge { - // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") - if let Some(last_segment) = merge_path.segments.last() { - let struct_name = last_segment.ident.to_string(); - let spec_file = vespera_dir.join(format!("{}.openapi.json", struct_name)); - - if let Ok(spec_content) = std::fs::read_to_string(&spec_file) - && let Ok(child_spec) = - serde_json::from_str::(&spec_content) - { - openapi_doc.merge(child_spec); - } + && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") + { + let manifest_path = Path::new(&manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + + for merge_path in &input.merge { + // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") + if let Some(last_segment) = merge_path.segments.last() { + let struct_name = last_segment.ident.to_string(); + let spec_file = vespera_dir.join(format!("{}.openapi.json", struct_name)); + + if let Ok(spec_content) = std::fs::read_to_string(&spec_file) + && let Ok(child_spec) = + serde_json::from_str::(&spec_content) + { + openapi_doc.merge(child_spec); } } } + } let json_str = serde_json::to_string_pretty(&openapi_doc) .map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?; @@ -577,9 +578,10 @@ fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { let cargo_toml = dir.join("Cargo.toml"); if cargo_toml.exists() && let Ok(contents) = std::fs::read_to_string(&cargo_toml) - && contents.contains("[workspace]") { - return dir.join("target"); - } + && contents.contains("[workspace]") + { + return dir.join("target"); + } current = dir.parent(); } diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 96e633d..ed1f830 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -9,6 +9,7 @@ vespera = { path = "../../crates/vespera" } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tower-http = { version = "0.6", features = ["cors"] } third = { path = "../third" } diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index 05e6f34..f44f51d 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -28,3 +28,24 @@ pub fn create_app() -> axum::Router { config: "test".to_string(), })) } + +/// Create the application router with a layer for testing VesperaRouter::layer +pub fn create_app_with_layer() -> axum::Router { + use tower_http::cors::{Any, CorsLayer}; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + vespera!( + openapi = ["examples/axum-example/openapi.json", "openapi.json"], + docs_url = "/docs", + redoc_url = "/redoc", + merge = [ThirdApp] + ) + .layer(cors) + .with_state(Arc::new(AppState { + config: "test".to_string(), + })) +} diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 8d5b8a1..d15eaf1 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -1,4 +1,4 @@ -use axum_example::create_app; +use axum_example::{create_app, create_app_with_layer}; use axum_test::TestServer; use serde_json::json; @@ -294,6 +294,111 @@ async fn test_mod_file_with_complex_struct_body_with_rename() { assert!(response_text.contains("renamed_value1")); } +// Tests for merged routes from third app +#[tokio::test] +async fn test_third_app_root_endpoint() { + let app = create_app(); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/third").await; + + response.assert_status_ok(); + response.assert_text("third app root endpoint"); +} + +#[tokio::test] +async fn test_third_app_hello_endpoint() { + let app = create_app(); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/third/hello").await; + + response.assert_status_ok(); + response.assert_text("third app hello endpoint"); +} + +#[tokio::test] +async fn test_third_app_map_query_endpoint() { + let app = create_app(); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/third/map-query?name=test&age=25").await; + + response.assert_status_ok(); + response.assert_text("third app map query endpoint"); +} + +#[tokio::test] +async fn test_third_app_map_query_with_optional() { + let app = create_app(); + let server = TestServer::new(app).unwrap(); + + let response = server + .get("/third/map-query?name=test&age=25&optional_age=30") + .await; + + response.assert_status_ok(); + response.assert_text("third app map query endpoint"); +} + +#[tokio::test] +async fn test_openapi_contains_third_app_routes() { + let openapi_content = std::fs::read_to_string("openapi.json").unwrap(); + let openapi: serde_json::Value = serde_json::from_str(&openapi_content).unwrap(); + + let paths = openapi.get("paths").unwrap(); + + // Verify third app routes are included in the merged OpenAPI spec + assert!( + paths.get("/third").is_some(), + "Missing /third route in OpenAPI spec" + ); + assert!( + paths.get("/third/hello").is_some(), + "Missing /third/hello route in OpenAPI spec" + ); + assert!( + paths.get("/third/map-query").is_some(), + "Missing /third/map-query route in OpenAPI spec" + ); +} + +#[tokio::test] +async fn test_openapi_contains_third_app_schemas() { + let openapi_content = std::fs::read_to_string("openapi.json").unwrap(); + let openapi: serde_json::Value = serde_json::from_str(&openapi_content).unwrap(); + + let schemas = openapi.get("components").and_then(|c| c.get("schemas")); + + // Verify third app schemas are included + assert!( + schemas.is_some(), + "Missing components/schemas in OpenAPI spec" + ); + let schemas = schemas.unwrap(); + assert!( + schemas.get("ThirdMapQuery").is_some(), + "Missing ThirdMapQuery schema in OpenAPI spec" + ); +} + +// Test VesperaRouter::layer functionality +#[tokio::test] +async fn test_app_with_layer() { + let app = create_app_with_layer(); + let server = TestServer::new(app).unwrap(); + + // Test that routes still work with the layer applied + let response = server.get("/health").await; + response.assert_status_ok(); + response.assert_text("ok"); + + // Test merged routes also work with layer + let response = server.get("/third").await; + response.assert_status_ok(); + response.assert_text("third app root endpoint"); +} + #[tokio::test] async fn test_openapi() { insta::assert_snapshot!("openapi", std::fs::read_to_string("openapi.json").unwrap());