From 168f08badf5b608a9677b664f516c67737175c58 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 22 May 2026 22:50:50 +0900 Subject: [PATCH 1/8] Refactor --- .gitignore | 1 + Cargo.lock | 257 +++++++++++++++++- apps/landing/package.json | 6 +- bun.lock | 161 ++++++----- crates/vespera_core/src/openapi.rs | 2 +- crates/vespera_core/src/route.rs | 2 +- crates/vespera_core/src/schema.rs | 4 +- crates/vespera_inprocess/Cargo.toml | 8 + crates/vespera_inprocess/benches/dispatch.rs | 227 ++++++++++++++++ crates/vespera_inprocess/src/lib.rs | 191 ++++++++++--- .../tests/method_validation.rs | 65 +++++ .../tests/register_app_idempotent.rs | 70 +++++ crates/vespera_macro/src/openapi_generator.rs | 40 ++- crates/vespera_macro/src/parser/operation.rs | 6 +- crates/vespera_macro/src/parser/parameters.rs | 2 +- crates/vespera_macro/src/parser/response.rs | 4 +- .../src/parser/schema/enum_schema.rs | 2 +- .../vespera_macro/src/schema_macro/codegen.rs | 10 +- .../src/schema_macro/file_cache.rs | 37 +-- crates/vespera_macro/src/schema_macro/mod.rs | 82 +++--- crates/vespera_macro/src/vespera_impl.rs | 53 ++-- examples/axum-example/Cargo.toml | 2 +- examples/axum-example/openapi.json | 142 ++++++++++ openapi.json | 142 ++++++++++ package.json | 9 +- 25 files changed, 1296 insertions(+), 229 deletions(-) create mode 100644 crates/vespera_inprocess/benches/dispatch.rs create mode 100644 crates/vespera_inprocess/tests/method_validation.rs create mode 100644 crates/vespera_inprocess/tests/register_app_idempotent.rs diff --git a/.gitignore b/.gitignore index a07b923..2ee8acb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ bin/ *.iml .idea/ .omc +.omo node_modules diff --git a/Cargo.lock b/Cargo.lock index 9a17bbd..11fe96b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -57,6 +66,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -535,6 +556,12 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.58" @@ -592,6 +619,58 @@ dependencies = [ "phf", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "combine" version = "4.6.7" @@ -698,6 +777,41 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "croner" version = "3.0.1" @@ -706,7 +820,26 @@ checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" dependencies = [ "chrono", "derive_builder", - "strum", + "strum 0.27.2", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -1573,6 +1706,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1983,6 +2125,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2016,6 +2164,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -2132,6 +2290,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "pluralizer" version = "0.5.0" @@ -2333,6 +2519,26 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2651,9 +2857,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131" +checksum = "9b5428ce6a0c8f6b9858df21ad1aa00c2fb94e1c9f344a0436bc855391e5a225" dependencies = [ "async-stream", "async-trait", @@ -2661,7 +2867,7 @@ dependencies = [ "chrono", "derive_more", "futures-util", - "itertools", + "itertools 0.14.0", "log", "mac_address", "ouroboros", @@ -2675,7 +2881,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.28.0", "thiserror", "time", "tracing", @@ -2696,12 +2902,12 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474" +checksum = "ae1374d83dd5b43f14dcc90fc726486c556f4db774b680b12b8c680af76e8233" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "pluralizer", "proc-macro2", "quote", @@ -2712,9 +2918,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.31" +version = "1.0.0-rc.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58decdaaaf2a698170af2fa1b2e8f7b43a970e7768bf18aebaab113bada46354" +checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" dependencies = [ "chrono", "inherent", @@ -2742,9 +2948,9 @@ dependencies = [ [[package]] name = "sea-query-sqlx" -version = "0.8.0-rc.14" +version = "0.8.0-rc.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4377164b09a11bb692dec6966eb0e6908d63d768defef0be689b39e02cf8544" +checksum = "a04aeecfe00614fece56336fd35dc385bb9ffed0c75660695ba925e42a3991ef" dependencies = [ "sea-query", "sqlx", @@ -3255,6 +3461,12 @@ dependencies = [ "strum_macros", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "strum_macros" version = "0.27.2" @@ -3413,6 +3625,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -3743,6 +3965,7 @@ name = "vespera_inprocess" version = "0.1.51" dependencies = [ "axum", + "criterion", "http", "http-body-util", "serde", @@ -3905,6 +4128,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/apps/landing/package.json b/apps/landing/package.json index 71b69c6..3839f67 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -13,12 +13,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.44", + "@devup-ui/components": "^0.1.45", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.4", + "@next/mdx": "^16.2.6", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -28,7 +28,7 @@ "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.0.2", + "shiki": "^4.1.0", "unified": "^11.0.5" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index 9eafb3e..53ce41d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,9 @@ "@devup-ui/bun-plugin": "^1.0", "@types/bun": "^1.3", "bun-test-env-dom": "^1.0", - "eslint": "9", - "eslint-plugin-devup": "^2.0.18", + "eslint-plugin-devup": "^2.0.19", "husky": "^9.1", - "oxlint": "^1.61.0", + "oxlint": "^1.66.0", }, }, "apps/landing": { @@ -20,12 +19,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.44", + "@devup-ui/components": "^0.1.45", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.4", + "@next/mdx": "^16.2.6", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -35,7 +34,7 @@ "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.0.2", + "shiki": "^4.1.0", "unified": "^11.0.5", }, "devDependencies": { @@ -103,7 +102,7 @@ "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.7", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-Sd3jeZ1swtAL+wf1STTW+Ay60jbZ9emWIJhnXfUK9+A/h+LLqiMpu9fI+m8IXxKM+5ijDF12lcrN+Vm3rT4o/g=="], - "@devup-ui/components": ["@devup-ui/components@0.1.44", "", { "dependencies": { "@devup-ui/react": "^1.0.35", "clsx": "^2.1", "react": "^19.2.4" } }, "sha512-yqkkfMr9LaBOaHdXWgPF0uI/J2b9s2LXpsrbQniOx+5GseogDtWhmg52jfr6GiFgpiHMPktDcKCWEfuTraMqQw=="], + "@devup-ui/components": ["@devup-ui/components@0.1.45", "", { "dependencies": { "@devup-ui/react": "^1.0.36", "clsx": "^2.1", "react": "^19.2.6" } }, "sha512-ND5G3nVT+3DzZS6BS4FHDP7b5pQ7/qu7l/Q4ZglxpkJo3B1XLD6O4ZpNap5NZ9J2YGPEmKT7qHdphd/qINvTtg=="], "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.57", "typescript-eslint": "^8.57" } }, "sha512-HLoIDIHgUsEJ4z8a0VGMx48DYIKfnv/jZPIQWFlK6s67n6x+R33loY+5O/mggbIDntGY2lknGTKFfKgD4hahPQ=="], @@ -125,19 +124,19 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], @@ -221,7 +220,7 @@ "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], - "@next/mdx": ["@next/mdx@16.2.4", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-e/3bgla+/oF3vDlndI0eFPa0bnP47HPVA0InsAJi7Jr3DwV8WpEGuOcm/3PdI5/93FfNiBhMVeVHZpm1sFlmJw=="], + "@next/mdx": ["@next/mdx@16.2.6", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-0hdoSkzRbyud1dNRRDiyqD9FrxR2wwdiW+ffhYx+n+fXrFOJ7Nwpi8o7nUz2LiiM44BB9M0eIO1Evy3BBrS50A=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], @@ -251,67 +250,67 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.61.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.61.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.61.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.61.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.61.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.61.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.61.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], - "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], - "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], - "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], - "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], - "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.5", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-WKt+xyxvMQkUL4sqMQ8l3gzCplNi9HedVQN32WmBJYKITJ9a5r3H5cpICp8y96V8ZL5rZH0EZRgpO6sy8fAgrQ=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.11", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-4JfaSf6/ql9AFAsRWaWulz40gS86bDgSr15pWCI3o+oX3sdZ0ZR8AOeNrCEqyIrV6wFxnCfhFi1kWjOlZ+66Ew=="], "@tanstack/query-core": ["@tanstack/query-core@5.100.5", "", {}, "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg=="], @@ -397,7 +396,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -427,11 +426,11 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -555,13 +554,13 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], "eslint-mdx": ["eslint-mdx@3.7.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "espree": "^9.6.1 || ^10.4.0", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "unified-engine": "^11.2.2", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0", "remark-lint-file-extension": "*" }, "optionalPeers": ["remark-lint-file-extension"] }, "sha512-QpPdJ6EeFthHuIrfgnWneZgwwFNOLFj/nf2jg/tOTBoiUnqNTxUUpTGAn0ZFHYEh5htVVoe5kjvD02oKtxZGeA=="], - "eslint-plugin-devup": ["eslint-plugin-devup@2.0.18", "", { "dependencies": { "@devup-ui/eslint-plugin": ">=1.0", "@eslint/js": ">=10.0", "@tanstack/eslint-plugin-query": ">=5", "eslint": ">=10.2", "eslint-config-prettier": ">=10", "eslint-plugin-mdx": ">=3", "eslint-plugin-prettier": ">=5", "eslint-plugin-react": ">=7", "eslint-plugin-react-hooks": ">=7", "eslint-plugin-simple-import-sort": ">=12", "eslint-plugin-unused-imports": ">=4", "prettier": ">=3", "typescript-eslint": ">=8.58" } }, "sha512-cva6GN5XE+f/lLcXU/TzLxjGI0aiGk8JZUU4CamoeTklCI3JCfMKlatTim8AE/riuje8q9Kjc9l/4YichBKDWw=="], + "eslint-plugin-devup": ["eslint-plugin-devup@2.0.19", "", { "dependencies": { "@devup-ui/eslint-plugin": ">=1.0.14", "@eslint/js": ">=10.0", "@tanstack/eslint-plugin-query": ">=5.100.6", "eslint": ">=10.2", "eslint-config-prettier": ">=10", "eslint-plugin-mdx": ">=3.7.0", "eslint-plugin-prettier": ">=5.5.5", "eslint-plugin-react": ">=7.37.5", "eslint-plugin-react-hooks": ">=7", "eslint-plugin-simple-import-sort": ">=13.0.0", "eslint-plugin-unused-imports": ">=4.4.1", "prettier": ">=3", "typescript-eslint": ">=8.59" } }, "sha512-E1CwZp4kjy/py/xztR1cXOF/FuzEuGc2GaYEK3cCaAtVna0rTT9TwxPKcTpGQIJvjlZHNxEl5BoeJdARC8GGPQ=="], "eslint-plugin-mdx": ["eslint-plugin-mdx@3.7.0", "", { "dependencies": { "eslint-mdx": "^3.7.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-JXaaQPnKqyti/QSOSQDThLV1EemHm/Fe2l/nMKH0vmhvmABtN/yV/9+GtKgh8UTZwrwuTfQq1HW5eR8HXneNLA=="], @@ -575,11 +574,11 @@ "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -913,7 +912,7 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -963,7 +962,7 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - "oxlint": ["oxlint@1.61.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.61.0", "@oxlint/binding-android-arm64": "1.61.0", "@oxlint/binding-darwin-arm64": "1.61.0", "@oxlint/binding-darwin-x64": "1.61.0", "@oxlint/binding-freebsd-x64": "1.61.0", "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", "@oxlint/binding-linux-arm-musleabihf": "1.61.0", "@oxlint/binding-linux-arm64-gnu": "1.61.0", "@oxlint/binding-linux-arm64-musl": "1.61.0", "@oxlint/binding-linux-ppc64-gnu": "1.61.0", "@oxlint/binding-linux-riscv64-gnu": "1.61.0", "@oxlint/binding-linux-riscv64-musl": "1.61.0", "@oxlint/binding-linux-s390x-gnu": "1.61.0", "@oxlint/binding-linux-x64-gnu": "1.61.0", "@oxlint/binding-linux-x64-musl": "1.61.0", "@oxlint/binding-openharmony-arm64": "1.61.0", "@oxlint/binding-win32-arm64-msvc": "1.61.0", "@oxlint/binding-win32-ia32-msvc": "1.61.0", "@oxlint/binding-win32-x64-msvc": "1.61.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ=="], + "oxlint": ["oxlint@1.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -1097,7 +1096,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -1271,8 +1270,16 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@devup-ui/components/@devup-ui/react": ["@devup-ui/react@1.0.36", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-FjRW7YCuVuusWOtFrD11kYO4KFxVFMoU/gRgBUMdhU9tzuCqrUFB+9glPXIbq6qFpFB4dkoUmNxv7XSEFZNzzw=="], + + "@devup-ui/components/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1295,15 +1302,15 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "eslint-mdx/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "eslint-plugin-devup/@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + "eslint-plugin-react/eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], - "eslint-plugin-devup/eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1323,8 +1330,6 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1345,10 +1350,18 @@ "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@devup-ui/components/@devup-ui/react/csstype-extra": ["csstype-extra@0.1.29", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-9y4phbWzHTetVUxRlx2Lm6WULf/ciwtZ0AmaQnI8pwtEHQMw6BXkXLXBnehGJGSFsZ4zXc6MOoBCfzPbHroMMQ=="], + + "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1357,23 +1370,25 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "eslint-plugin-react/eslint/@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - "eslint-plugin-devup/eslint/@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + "eslint-plugin-react/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "eslint-plugin-devup/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + "eslint-plugin-react/eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - "eslint-plugin-devup/eslint/@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + "eslint-plugin-react/eslint/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - "eslint-plugin-devup/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "eslint-plugin-react/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "eslint-plugin-devup/eslint/eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + "eslint-plugin-react/eslint/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - "eslint-plugin-devup/eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "eslint-plugin-react/eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "eslint-plugin-devup/eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + "eslint-plugin-react/eslint/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "eslint-plugin-devup/eslint/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -1383,12 +1398,14 @@ "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "eslint-plugin-devup/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + "eslint-plugin-react/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "eslint-plugin-devup/eslint/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "eslint-plugin-devup/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index 5c3ee25..f8be93b 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; /// `OpenAPI` document version -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum OpenApiVersion { #[serde(rename = "3.0.0")] V3_0_0, diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index 72caf9b..5832875 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -53,7 +53,7 @@ impl TryFrom<&str> for HttpMethod { } /// Parameter location in the request -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ParameterLocation { Query, diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 35e8d5f..c9e6b6f 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -36,7 +36,7 @@ impl Reference { } /// JSON Schema type -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SchemaType { String, @@ -364,7 +364,7 @@ pub struct Components { } /// Security scheme type -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SecuritySchemeType { ApiKey, diff --git a/crates/vespera_inprocess/Cargo.toml b/crates/vespera_inprocess/Cargo.toml index 3ddf622..756b00b 100644 --- a/crates/vespera_inprocess/Cargo.toml +++ b/crates/vespera_inprocess/Cargo.toml @@ -15,5 +15,13 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt"] } +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[[bench]] +name = "dispatch" +harness = false + [lints] workspace = true diff --git a/crates/vespera_inprocess/benches/dispatch.rs b/crates/vespera_inprocess/benches/dispatch.rs new file mode 100644 index 0000000..3d93e35 --- /dev/null +++ b/crates/vespera_inprocess/benches/dispatch.rs @@ -0,0 +1,227 @@ +//! Criterion benchmarks quantifying the performance review patches. +//! +//! Each benchmark group compares **two paths** that are both reachable +//! from the *current* code base, so a single `cargo bench` run produces +//! the before/after comparison without git tricks: +//! +//! - `router_path`: `Router::clone()` of a pre-built router (post-P1) +//! vs rebuilding the router from a factory closure (pre-P1, simulated). +//! - `dispatch_path`: `dispatch_owned(router, env)` (post-P2) +//! vs `dispatch(router, &env)` which clones internally (pre-P2). +//! - `full_flow`: realistic JNI flow `dispatch_from_json`-style — parse + +//! cached router + owned dispatch (post-P1+P2) vs parse + per-call +//! build + borrowed dispatch (pre-P1+P2). +//! +//! Scaling axes: +//! - `route_count`: 10 / 100 / 500 routes (Router-build dominance). +//! - `body_kb`: 1 / 64 / 1024 KB request bodies (body-clone dominance). + +use std::collections::HashMap; + +use axum::{ + Json, Router, + routing::{get, post}, +}; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use serde::{Deserialize, Serialize}; +use tokio::runtime::Runtime; +use vespera_inprocess::{RequestEnvelope, dispatch, dispatch_owned, dispatch_typed, parse_request}; + +// ── Test fixtures ──────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct Echo { + body: String, +} + +async fn handler_get() -> Json { + Json(serde_json::json!({ "ok": true })) +} + +async fn handler_echo(Json(payload): Json) -> Json { + Json(payload) +} + +/// Build a router with `n_routes` distinct GET endpoints plus one +/// `POST /echo` that echoes the request body. This simulates the +/// `vespera!()` macro-expanded `Router::new().route(...).route(...)...` +/// chain that runs inside the user's `create_app()`. +fn build_router(n_routes: usize) -> Router { + let mut router = Router::new().route("/echo", post(handler_echo)); + for i in 0..n_routes { + let path = format!("/r{i}"); + router = router.route(&path, get(handler_get)); + } + router +} + +/// JSON-encoded `RequestEnvelope` whose body is `body_kb * 1024` bytes +/// of valid UTF-8 (so we measure the realistic clone/move cost without +/// triggering the lossy decode path). +fn make_envelope_json(body_kb: usize) -> String { + let body_str = "x".repeat(body_kb * 1024); + let envelope = serde_json::json!({ + "method": "POST", + "path": "/echo", + "query": "", + "headers": { "content-type": "application/json" }, + "body": serde_json::to_string(&Echo { body: body_str }).unwrap(), + }); + envelope.to_string() +} + +/// Owned `RequestEnvelope` mirror of `make_envelope_json` for the +/// dispatch-only benches that skip the JSON parse step. +fn make_envelope(body_kb: usize) -> RequestEnvelope { + let body_str = "x".repeat(body_kb * 1024); + let mut headers = HashMap::new(); + headers.insert("content-type".to_owned(), "application/json".to_owned()); + RequestEnvelope { + method: "POST".to_owned(), + path: "/echo".to_owned(), + query: String::new(), + headers, + body: serde_json::to_string(&Echo { body: body_str }).unwrap(), + } +} + +// ── Naive (pre-patch) reference paths ──────────────────────────────── + +/// Simulates the pre-patch `dispatch_from_json`: +/// factory() per call + dispatch with borrowed envelope (internal clone). +fn naive_dispatch_from_json( + input: &str, + runtime: &Runtime, + factory: &dyn Fn() -> Router, +) -> String { + let envelope = parse_request(input).expect("valid envelope"); + let router = factory(); // pre-P1: factory called per request + runtime.block_on(dispatch(router, &envelope)) // pre-P2: dispatch clones envelope internally +} + +/// Simulates the post-patch hot path explicitly so the comparison +/// against `naive_dispatch_from_json` is apples-to-apples (no detour +/// through the global `APP_ROUTER` `OnceLock`). +fn patched_dispatch_from_json(input: &str, runtime: &Runtime, cached_router: &Router) -> String { + let envelope = parse_request(input).expect("valid envelope"); + let router = cached_router.clone(); // post-P1: cheap Arc-backed clone + let response = runtime.block_on(dispatch_owned(router, envelope)); + serde_json::to_string(&response).expect("response is serializable") +} + +// ── Benchmarks ─────────────────────────────────────────────────────── + +/// P1 isolation: cached Router::clone() vs factory rebuild per call. +/// Dispatch step is identical (`dispatch_owned`) on both sides so any +/// delta is attributable to router construction. +fn bench_router_path(c: &mut Criterion) { + let runtime = Runtime::new().expect("tokio runtime"); + let envelope_template = make_envelope(1); // 1 KB body, fixed + let mut group = c.benchmark_group("router_path"); + + for &n_routes in &[10_usize, 100, 500] { + let cached = build_router(n_routes); + + group.bench_with_input( + BenchmarkId::new("cached_clone_post_P1", n_routes), + &n_routes, + |b, _| { + b.iter(|| { + let router = cached.clone(); + runtime.block_on(dispatch_owned(router, envelope_template.clone())) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("factory_rebuild_pre_P1", n_routes), + &n_routes, + |b, &n| { + b.iter(|| { + let router = build_router(n); + runtime.block_on(dispatch_owned(router, envelope_template.clone())) + }); + }, + ); + } + + group.finish(); +} + +/// P2 isolation: `dispatch_owned` (envelope moved into HTTP request) vs +/// `dispatch_typed` (envelope borrowed → clone then `dispatch_owned` +/// internally). Each iteration **freshly parses** the envelope from JSON +/// so the owned path genuinely avoids a clone; the borrowed path pays +/// for exactly one extra `RequestEnvelope::clone()` inside +/// `dispatch_typed`. Both arms return `ResponseEnvelope` so the +/// response-JSON serialization cost is excluded. +fn bench_dispatch_path(c: &mut Criterion) { + let runtime = Runtime::new().expect("tokio runtime"); + let cached = build_router(20); + let mut group = c.benchmark_group("dispatch_path"); + + for &body_kb in &[1_usize, 64, 1024] { + let envelope_json = make_envelope_json(body_kb); + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + group.bench_with_input( + BenchmarkId::new("owned_post_P2", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let env = parse_request(&envelope_json).expect("valid envelope"); + runtime.block_on(dispatch_owned(cached.clone(), env)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("borrowed_pre_P2", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let env = parse_request(&envelope_json).expect("valid envelope"); + runtime.block_on(dispatch_typed(cached.clone(), &env)) + }); + }, + ); + } + + group.finish(); +} + +/// End-to-end JNI-style flow: JSON in → JSON out. Combines P1 + P2 so +/// the headline “Router rebuild + body clone” cost is visible. +fn bench_full_flow(c: &mut Criterion) { + let runtime = Runtime::new().expect("tokio runtime"); + let cached_100 = build_router(100); + let mut group = c.benchmark_group("full_flow"); + + for &body_kb in &[1_usize, 64, 1024] { + let envelope_json = make_envelope_json(body_kb); + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + group.bench_with_input( + BenchmarkId::new("patched_post_P1_P2", body_kb), + &body_kb, + |b, _| { + b.iter(|| patched_dispatch_from_json(&envelope_json, &runtime, &cached_100)); + }, + ); + + group.bench_with_input( + BenchmarkId::new("naive_pre_P1_P2", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + naive_dispatch_from_json(&envelope_json, &runtime, &|| build_router(100)) + }); + }, + ); + } + + group.finish(); +} + +criterion_group!(benches, bench_router_path, bench_dispatch_path, bench_full_flow); +criterion_main!(benches); diff --git a/crates/vespera_inprocess/src/lib.rs b/crates/vespera_inprocess/src/lib.rs index 9d601fc..66290ea 100644 --- a/crates/vespera_inprocess/src/lib.rs +++ b/crates/vespera_inprocess/src/lib.rs @@ -4,7 +4,7 @@ //! This crate is **transport-agnostic** — it knows nothing about JNI, //! C FFI, or WASM. It provides: //! -//! 1. [`dispatch`] / [`dispatch_typed`] — drive a Router with an envelope +//! 1. [`dispatch`] / [`dispatch_typed`] / [`dispatch_owned`] — drive a Router with an envelope //! 2. [`register_app`] / [`dispatch_from_json`] — global app factory //! for any FFI boundary (JNI, C, WASM) //! @@ -23,8 +23,21 @@ //! // On each FFI call //! let response_json = vespera_inprocess::dispatch_from_json(request_json); //! ``` +//! +//! # Router caching semantics +//! +//! [`register_app`] invokes the supplied factory **once** at registration +//! time and stores the resulting [`Router`]. Subsequent +//! [`dispatch_from_json`] calls reuse the cached router via +//! [`Router::clone`], which is cheap because axum's router is internally +//! `Arc`-shared. This avoids rebuilding the route tree on every FFI +//! request. +//! +//! [`dispatch_json_with`] retains the per-call factory contract for +//! tests that do not want global state. use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::sync::OnceLock; use axum::body::Body; @@ -39,7 +52,7 @@ pub use axum::Router; // ── Envelope Types ─────────────────────────────────────────────────── /// Inbound request envelope. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize)] pub struct RequestEnvelope { pub method: String, pub path: String, @@ -78,13 +91,30 @@ pub struct ResponseEnvelope { /// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and /// return the serialised [`ResponseEnvelope`] JSON. +/// +/// This borrows the envelope and clones its owned fields before passing +/// them to the hot path. Callers that already own a [`RequestEnvelope`] +/// should prefer [`dispatch_owned`] to skip the clone. pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { - let result = dispatch_inner(router, envelope).await; + let result = dispatch_owned(router, envelope.clone()).await; serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") } /// Typed dispatch — returns a [`ResponseEnvelope`] directly. +/// +/// See [`dispatch`] for the clone trade-off; prefer [`dispatch_owned`] +/// when the envelope is already owned. pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { + dispatch_owned(router, envelope.clone()).await +} + +/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into the +/// HTTP request so the body, path, and headers are never cloned. +/// +/// This is the hot path used by [`dispatch_from_json`] / +/// [`dispatch_json_with`] and is exported for callers (e.g. custom FFI +/// transports) that already own a freshly parsed envelope. +pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { dispatch_inner(router, envelope).await } @@ -112,26 +142,43 @@ pub fn error_envelope(message: &str) -> ResponseEnvelope { // ── App Factory (shared FFI pattern) ───────────────────────────────── -type AppFactory = Box Router + Send + Sync>; - -static APP_FACTORY: OnceLock = OnceLock::new(); +static APP_ROUTER: OnceLock = OnceLock::new(); /// Register a global router factory. /// /// Any FFI boundary (JNI, C, WASM) calls this once at init time, /// then uses [`dispatch_from_json`] on each request. /// -/// # Panics +/// The factory is invoked **once** at registration time; the resulting +/// [`Router`] is cached and cheaply cloned on every dispatch. Callers +/// that need to rebuild the router (e.g. for dev-only hot reload) must +/// instead pass a factory directly to [`dispatch_json_with`]. +/// +/// # Second-call semantics /// -/// Panics if called more than once. +/// If `register_app` has already been called in this process the second +/// (and later) calls are a **no-op** — the originally registered router +/// is preserved and the new `factory` closure is **not invoked**. This +/// is friendlier to environments that legitimately load the cdylib twice +/// (test harnesses that re-init the global, hot-reloading JVM hosts, +/// dynamic plugin systems) than the previous panic-on-double-call +/// behaviour. Because the new factory is never invoked, it is safe for +/// the closure to perform expensive or strictly-once work — that work +/// will not be repeated. pub fn register_app(factory: F) where F: Fn() -> Router + Send + Sync + 'static, { - assert!( - APP_FACTORY.set(Box::new(factory)).is_ok(), - "vespera_inprocess::register_app called more than once" - ); + // Short-circuit if already registered. Avoids running `factory()` + // a second time only to drop its result. + if APP_ROUTER.get().is_some() { + return; + } + let router = factory(); + // `set` may still return `Err` if another thread won the race + // between the `get` above and here; that is also a no-op — the + // winning registration is preserved. + let _ = APP_ROUTER.set(router); } /// Dispatch a JSON request string through the registered app. @@ -140,20 +187,32 @@ where /// on the current thread (the caller provides it — e.g. JNI crate /// uses a `LazyLock`). pub fn dispatch_from_json(input: &str, runtime: &tokio::runtime::Runtime) -> String { - APP_FACTORY.get().map_or_else( - || serialize_error("no app registered — call register_app() at init time"), - |factory| dispatch_json_with(input, runtime, factory.as_ref()), - ) + let Some(router) = APP_ROUTER.get() else { + return serialize_error("no app registered — call register_app() at init time"); + }; + match parse_request(input) { + Ok(envelope) => { + let response = runtime.block_on(dispatch_owned(router.clone(), envelope)); + serde_json::to_string(&response).expect("ResponseEnvelope serialization is infallible") + } + Err(msg) => serialize_error(&msg), + } } /// Dispatch with an explicit factory — fully testable without global state. +/// +/// The factory is invoked on every call. For the cached-router path +/// used by FFI dispatch, see [`dispatch_from_json`]. pub fn dispatch_json_with( input: &str, runtime: &tokio::runtime::Runtime, factory: &dyn Fn() -> Router, ) -> String { match parse_request(input) { - Ok(envelope) => runtime.block_on(dispatch(factory(), &envelope)), + Ok(envelope) => { + let response = runtime.block_on(dispatch_owned(factory(), envelope)); + serde_json::to_string(&response).expect("ResponseEnvelope serialization is infallible") + } Err(msg) => serialize_error(&msg), } } @@ -165,27 +224,60 @@ pub fn serialize_error(msg: &str) -> String { // ── Internal ───────────────────────────────────────────────────────── -async fn dispatch_inner(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { +async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { let version = env!("CARGO_PKG_VERSION").to_owned(); - let uri = if envelope.query.is_empty() { - envelope.path.clone() + let RequestEnvelope { + method, + path, + query, + headers, + body, + } = envelope; + + let uri = if query.is_empty() { + path } else { - format!("{}?{}", envelope.path, envelope.query) + format!("{path}?{query}") }; - let http_method = envelope.method.parse::().unwrap_or(Method::GET); + // Parse the HTTP method explicitly. Previously an invalid method + // (e.g. an empty string, whitespace, a malformed token) was + // silently coerced to `GET`, causing the router to dispatch the + // request to whichever handler happened to live at that path's GET + // route. That is a correctness footgun — a malformed method + // would return 200 from a GET handler instead of the expected + // method-not-allowed response. We now short-circuit with + // `405 Method Not Allowed` before the router is consulted. + // + // Note: well-formed but unknown methods (e.g. `BREW`) still reach + // the router and let axum produce the canonical 405 itself. + let Ok(http_method) = method.parse::() else { + return ResponseEnvelope { + status: 405, + headers: HashMap::new(), + body: format!("Method Not Allowed: '{method}' is not a valid HTTP method"), + metadata: ResponseMetadata { version }, + }; + }; + + // Case-insensitive Content-Type detection (RFC 7230 §3.2 — header + // names are case-insensitive). Avoids double-injecting application/json + // when callers send "Content-Type" or "CONTENT-TYPE". + let has_content_type = headers + .keys() + .any(|k| k.eq_ignore_ascii_case("content-type")); let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &envelope.headers { + for (name, value) in &headers { builder = builder.header(name.as_str(), value.as_str()); } - if !envelope.body.is_empty() && !envelope.headers.contains_key("content-type") { + if !body.is_empty() && !has_content_type { builder = builder.header("content-type", "application/json"); } let request = builder - .body(Body::from(envelope.body.clone())) + .body(Body::from(body)) .expect("request construction should not fail with valid URI"); let response = router @@ -195,33 +287,46 @@ async fn dispatch_inner(router: Router, envelope: &RequestEnvelope) -> ResponseE let status = response.status().as_u16(); - let mut raw_headers: HashMap> = HashMap::new(); + // Single-pass response header conversion: collapse repeated header + // names into HeaderValue::Multi without an intermediate + // HashMap>. + let mut resp_headers: HashMap = + HashMap::with_capacity(response.headers().len()); for (name, value) in response.headers() { - raw_headers - .entry(name.as_str().to_owned()) - .or_default() - .push(value.to_str().unwrap_or("").to_owned()); - } - - let headers = raw_headers - .into_iter() - .map(|(k, mut v)| { - if v.len() == 1 { - (k, HeaderValue::Single(v.remove(0))) - } else { - (k, HeaderValue::Multi(v)) + let val_str = value.to_str().unwrap_or("").to_owned(); + match resp_headers.entry(name.as_str().to_owned()) { + Entry::Vacant(e) => { + e.insert(HeaderValue::Single(val_str)); } - }) - .collect(); + Entry::Occupied(mut e) => { + let slot = e.get_mut(); + let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { + HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), + HeaderValue::Multi(mut v) => { + v.push(val_str); + HeaderValue::Multi(v) + } + }; + *slot = new_slot; + } + } + } + // Body decode: avoid `Bytes -> Vec -> String` indirection. + // `from_utf8_lossy` borrows the bytes; if they are valid UTF-8 the + // owned String is allocated once. Invalid sequences are replaced + // with U+FFFD instead of being silently dropped to an empty string, + // which surfaces non-UTF-8 responses to callers. For true binary + // payloads, an additive `body_bytes` field on `ResponseEnvelope` + // remains a follow-up. let body_str = response.into_body().collect().await.map_or_else( |_| String::new(), - |c| String::from_utf8(c.to_bytes().to_vec()).unwrap_or_default(), + |collected| String::from_utf8_lossy(&collected.to_bytes()).into_owned(), ); ResponseEnvelope { status, - headers, + headers: resp_headers, body: body_str, metadata: ResponseMetadata { version }, } diff --git a/crates/vespera_inprocess/tests/method_validation.rs b/crates/vespera_inprocess/tests/method_validation.rs new file mode 100644 index 0000000..a57d233 --- /dev/null +++ b/crates/vespera_inprocess/tests/method_validation.rs @@ -0,0 +1,65 @@ +//! Integration tests for the malformed-HTTP-method correctness fix: +//! invalid method strings now short-circuit to `405 Method Not Allowed` +//! instead of being silently coerced to `GET` (which would dispatch the +//! request to the wrong handler). + +use std::collections::HashMap; + +use axum::Router; +use axum::routing::get; +use vespera_inprocess::{RequestEnvelope, dispatch_typed}; + +fn envelope_with_method(method: &str) -> RequestEnvelope { + RequestEnvelope { + method: method.to_owned(), + path: "/test".to_owned(), + query: String::new(), + headers: HashMap::new(), + body: String::new(), + } +} + +fn router_with_get_test() -> Router { + Router::new().route("/test", get(|| async { "would-have-been-wrong" })) +} + +#[tokio::test(flavor = "current_thread")] +async fn method_with_space_returns_405() { + // Before the fix, "BAD METHOD" was silently coerced to GET and the + // request hit the GET handler at /test with status 200. + let response = dispatch_typed( + router_with_get_test(), + &envelope_with_method("BAD METHOD"), + ) + .await; + assert_eq!(response.status, 405); + assert!( + response.body.contains("BAD METHOD"), + "405 body should mention the offending method, got: {body}", + body = response.body, + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn empty_method_returns_405() { + let response = + dispatch_typed(router_with_get_test(), &envelope_with_method("")).await; + assert_eq!(response.status, 405); +} + +#[tokio::test(flavor = "current_thread")] +async fn method_with_control_char_returns_405() { + let response = + dispatch_typed(router_with_get_test(), &envelope_with_method("GET\n")).await; + assert_eq!(response.status, 405); +} + +#[tokio::test(flavor = "current_thread")] +async fn valid_method_dispatches_normally() { + // Sanity check: a real GET still reaches the handler. The 405 + // short-circuit must not regress the happy path. + let response = + dispatch_typed(router_with_get_test(), &envelope_with_method("GET")).await; + assert_eq!(response.status, 200); + assert_eq!(response.body, "would-have-been-wrong"); +} diff --git a/crates/vespera_inprocess/tests/register_app_idempotent.rs b/crates/vespera_inprocess/tests/register_app_idempotent.rs new file mode 100644 index 0000000..9b23dbd --- /dev/null +++ b/crates/vespera_inprocess/tests/register_app_idempotent.rs @@ -0,0 +1,70 @@ +//! Integration test for the `register_app` first-wins semantics: +//! a second (or later) `register_app` call must be a no-op that +//! preserves the originally registered router, without invoking the +//! supplied factory closure a second time. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::routing::get; +use vespera_inprocess::{dispatch_from_json, register_app}; + +#[test] +fn second_register_is_noop_first_wins() { + let invocations = Arc::new(AtomicUsize::new(0)); + + let inv = Arc::clone(&invocations); + register_app(move || { + inv.fetch_add(1, Ordering::SeqCst); + Router::new().route("/from-first", get(|| async { "first" })) + }); + + let inv = Arc::clone(&invocations); + register_app(move || { + inv.fetch_add(100, Ordering::SeqCst); + Router::new().route("/from-second", get(|| async { "second" })) + }); + + register_app(|| { + unreachable!( + "third register_app call must be a no-op without invoking the factory" + ); + }); + + assert_eq!( + invocations.load(Ordering::SeqCst), + 1, + "only the first register_app should have invoked its factory; \ + later calls must short-circuit before running the closure" + ); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + + // First registration's route must be reachable. + let response = + dispatch_from_json(r#"{"method":"GET","path":"/from-first"}"#, &runtime); + let v: serde_json::Value = + serde_json::from_str(&response).expect("response is JSON"); + assert_eq!( + v["status"].as_u64().expect("status is integer"), + 200, + "first registration's route must still be reachable after the no-op second register_app" + ); + + // Second registration's route must NOT be reachable — the second + // factory was never invoked so the router was never built, much less + // installed. + let response = + dispatch_from_json(r#"{"method":"GET","path":"/from-second"}"#, &runtime); + let v: serde_json::Value = + serde_json::from_str(&response).expect("response is JSON"); + assert_eq!( + v["status"].as_u64().expect("status is integer"), + 404, + "second registration was a no-op — its route must not exist on the registered router" + ); +} diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 5aaa4b9..33cd413 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -240,17 +240,11 @@ fn build_path_items( let mut paths = BTreeMap::new(); let mut all_tags = BTreeSet::new(); - // Primary source: pre-parse function items from ROUTE_STORAGE (populated by #[route]) - let route_fn_cache: HashMap<&str, syn::ItemFn> = route_storage - .iter() - .filter_map(|s| { - syn::parse_str::(&s.fn_item_str) - .ok() - .map(|item| (s.fn_name.as_str(), item)) - }) - .collect(); - - // Fallback source: function index from file ASTs (for routes not in ROUTE_STORAGE) + // Build the file-AST function index FIRST so the storage-parse step + // below can skip any function whose AST is already reachable through + // `file_cache`. `collector::collect_metadata` has already walked + // these files via `syn::parse_file`, so re-parsing `fn_item_str` + // from ROUTE_STORAGE for the same function is pure duplicated work. let fn_index: HashMap<&str, HashMap> = file_cache .iter() .map(|(path, ast)| { @@ -269,6 +263,30 @@ fn build_path_items( }) .collect(); + // Primary source: parse function items from ROUTE_STORAGE only when + // the function is *not* already covered by `fn_index`. Routes whose + // owning file is in `file_cache` short-circuit through `fn_index` in + // the loop below, so the parse is wasted work. The lookup order in + // the loop preserves the original ROUTE_STORAGE-first priority for + // any route that does end up in this cache (e.g. routes registered + // via `#[route]` from files outside the scanned routes folder). + let route_fn_cache: HashMap<&str, syn::ItemFn> = route_storage + .iter() + .filter_map(|s| { + let already_in_ast = s + .file_path + .as_deref() + .and_then(|fp| fn_index.get(fp)) + .is_some_and(|fns| fns.contains_key(&s.fn_name)); + if already_in_ast { + return None; + } + syn::parse_str::(&s.fn_item_str) + .ok() + .map(|item| (s.fn_name.as_str(), item)) + }) + .collect(); + for route_meta in &metadata.routes { // Try ROUTE_STORAGE first (avoids file_cache dependency for known routes) let fn_sig = if let Some(cached_fn) = route_fn_cache.get(route_meta.function_name.as_str()) diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index fbf57cb..bfedbd2 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -238,7 +238,7 @@ mod tests { fn param_schema_type(param: &Parameter) -> Option { match param.schema.as_ref()? { - SchemaRef::Inline(schema) => schema.schema_type.clone(), + SchemaRef::Inline(schema) => schema.schema_type, SchemaRef::Ref(_) => None, } } @@ -293,7 +293,7 @@ mod tests { if let Some(schema_ty) = &exp.schema { match media.schema.as_ref().expect("schema expected") { SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(schema_ty.clone())); + assert_eq!(schema.schema_type, Some(*schema_ty)); } SchemaRef::Ref(_) => panic!("expected inline schema"), } @@ -327,7 +327,7 @@ mod tests { if let Some(schema_ty) = &exp.schema { match media.schema.as_ref().expect("schema expected") { SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(schema_ty.clone())); + assert_eq!(schema.schema_type, Some(*schema_ty)); } SchemaRef::Ref(_) => panic!("expected inline schema"), } diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 5fd3447..551d783 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -627,7 +627,7 @@ mod tests { } let params = result.as_ref().expect("Expected Some parameters"); - let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); + let got_locs: Vec = params.iter().map(|p| p.r#in).collect(); assert_eq!( got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}" diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 233577f..c63e3ca 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -306,7 +306,7 @@ mod tests { fn assert_schema_matches(schema_ref: &SchemaRef, expected: &ExpectedSchema) { match schema_ref { SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(expected.schema_type.clone())); + assert_eq!(schema.schema_type, Some(expected.schema_type)); assert_eq!(schema.nullable.unwrap_or(false), expected.nullable); if let Some(item_ty) = &expected.items_schema_type { let items = schema @@ -315,7 +315,7 @@ mod tests { .expect("items should be present for array"); match items.as_ref() { SchemaRef::Inline(item_schema) => { - assert_eq!(item_schema.schema_type, Some(item_ty.clone())); + assert_eq!(item_schema.schema_type, Some(*item_ty)); } SchemaRef::Ref(_) => panic!("expected inline schema for array items"), } diff --git a/crates/vespera_macro/src/parser/schema/enum_schema.rs b/crates/vespera_macro/src/parser/schema/enum_schema.rs index 84751dc..c43a952 100644 --- a/crates/vespera_macro/src/parser/schema/enum_schema.rs +++ b/crates/vespera_macro/src/parser/schema/enum_schema.rs @@ -755,7 +755,7 @@ mod tests { let one_of = schema.clone().one_of.expect("one_of missing"); assert_eq!(one_of.len(), expected_one_of_len); - if let Some(inner_expected) = expected_inner_type.clone() { + if let Some(inner_expected) = expected_inner_type { if let SchemaRef::Inline(obj) = &one_of[0] { let props = obj.properties.as_ref().expect("props missing"); // take first property value diff --git a/crates/vespera_macro/src/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index 11c2c38..c613b76 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -115,8 +115,12 @@ pub fn generate_filtered_schema( } } -/// Convert `SchemaType` enum variant to its `TokenStream` representation -fn schema_type_to_tokens(st: &SchemaType) -> TokenStream { +/// Convert `SchemaType` enum variant to its `TokenStream` representation. +/// +/// `SchemaType` is a unit enum that derives `Copy`, so taking it by value +/// is strictly cheaper than borrowing (satisfies +/// `clippy::trivially_copy_pass_by_ref`). +fn schema_type_to_tokens(st: SchemaType) -> TokenStream { let variant = match st { SchemaType::String => "String", SchemaType::Number => "Number", @@ -157,7 +161,7 @@ pub fn schema_to_tokens(schema: &Schema) -> TokenStream { let mut fields: Vec = Vec::with_capacity(4); // schema_type - if let Some(st) = &schema.schema_type { + if let Some(st) = schema.schema_type { let st_tokens = schema_type_to_tokens(st); fields.push(quote! { schema_type: Some(#st_tokens) }); } diff --git a/crates/vespera_macro/src/schema_macro/file_cache.rs b/crates/vespera_macro/src/schema_macro/file_cache.rs index 0656d69..5931354 100644 --- a/crates/vespera_macro/src/schema_macro/file_cache.rs +++ b/crates/vespera_macro/src/schema_macro/file_cache.rs @@ -14,6 +14,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::SystemTime; use super::circular::CircularAnalysis; @@ -27,7 +28,12 @@ struct FileCache { /// Cached file contents: file path → (mtime, content string). /// Mtime is checked to invalidate stale entries in long-lived processes. - file_contents: HashMap, + /// + /// `Arc` lets the cache hand out cheap pointer-clones instead of + /// copying the entire file body on every lookup. The previous `String` + /// variant cloned `O(file_size)` bytes per cache hit and a second time + /// on insert; both become single-word `Arc::clone`s. + file_contents: HashMap)>, /// Struct name candidate index: (src_dir, struct_name) → files containing that name. /// Built from cheap `String::contains` search, not full parsing. @@ -244,7 +250,10 @@ pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option { /// Internal helper: get file content from cache or read from disk. /// Checks mtime for invalidation. -fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option { +/// +/// Returns `Arc` so callers share a single allocation instead of +/// cloning the whole file body per lookup. +fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option> { let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); if let Some(mtime) = current_mtime @@ -252,17 +261,17 @@ fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option && *cached_mtime == mtime { cache.content_cache_hits += 1; - return Some(content.clone()); + return Some(Arc::clone(content)); } // Cache miss or stale — read and cache - let content = std::fs::read_to_string(path).ok()?; + let content = Arc::new(std::fs::read_to_string(path).ok()?); cache.file_disk_reads += 1; if let Some(mtime) = current_mtime { cache .file_contents - .insert(path.to_path_buf(), (mtime, content.clone())); + .insert(path.to_path_buf(), (mtime, Arc::clone(&content))); } Some(content) @@ -381,21 +390,17 @@ pub fn get_module_path_from_schema_path(schema_path: &proc_macro2::TokenStream) return result; } - // 2. Compute from the string directly (avoids double to_string()) - let segments: Vec<&str> = path_str + // 2. Compute directly: collect once, pop the trailing schema segment. + // The previous version built an intermediate `Vec<&str>` and then + // re-allocated it into a `Vec` (one wasted allocation per + // cache miss). + let mut result: Vec = path_str .split("::") .map(str::trim) .filter(|s| !s.is_empty()) + .map(ToString::to_string) .collect(); - - let result = if segments.len() > 1 { - segments[..segments.len() - 1] - .iter() - .map(ToString::to_string) - .collect() - } else { - vec![] - }; + result.pop(); // drop the trailing segment (the schema name itself) // 3. Store — new borrow FILE_CACHE.with(|cache| { diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 59d230e..32188f9 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -18,6 +18,7 @@ mod validation; pub use file_cache::print_profile_summary; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use codegen::generate_filtered_schema; @@ -70,12 +71,16 @@ fn derive_response_base_name(name: &str) -> String { name.to_string() } -fn find_same_file_struct_metadata( +fn find_same_file_struct_metadata<'a>( struct_name: &str, - schema_storage: &HashMap, -) -> Option { + schema_storage: &'a HashMap, +) -> Option> { + // Cache hit: hand back a borrow so the (potentially large) struct + // definition string is not cloned per lookup. The fallback path + // produces an owned `StructMetadata` from disk, so the unified return + // type is `Cow<'_, StructMetadata>`. if let Some(metadata) = schema_storage.get(struct_name) { - return Some(metadata.clone()); + return Some(Cow::Borrowed(metadata)); } let file_path = proc_macro2::Span::call_site().local_file(); @@ -90,7 +95,10 @@ fn find_same_file_struct_metadata( }); let file_path = file_path?; let definition = file_cache::get_struct_definition(&file_path, struct_name)?; - Some(StructMetadata::new(struct_name.to_string(), definition)) + Some(Cow::Owned(StructMetadata::new( + struct_name.to_string(), + definition, + ))) } fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { @@ -99,19 +107,19 @@ fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option String { - let segments: Vec = schema_path - .to_string() - .split("::") - .map(|segment| segment.trim().to_string()) - .collect(); - - if segments.last().is_some_and(|segment| segment == "Schema") && segments.len() > 1 { - format!("{}Schema", capitalize_first(&segments[segments.len() - 2])) + // Keep the stringified path alive in this scope so the `&str` + // segments borrow from it. The previous implementation collected + // owned `String`s — one allocation per path segment — even though + // each segment is only ever inspected as `&str`. + let path_str = schema_path.to_string(); + let segments: Vec<&str> = path_str.split("::").map(str::trim).collect(); + + if segments.last().is_some_and(|s| *s == "Schema") && segments.len() > 1 { + format!("{}Schema", capitalize_first(segments[segments.len() - 2])) } else { segments .last() - .cloned() - .unwrap_or_else(|| "Schema".to_string()) + .map_or_else(|| "Schema".to_string(), |s| (*s).to_string()) } } @@ -282,10 +290,16 @@ fn maybe_generate_same_file_relation_override( let source_expr = quote! { source }; let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; - let mut helper_tokens = Vec::new(); - - if !has_derive(&dto_struct, "Clone") { - helper_tokens.push(quote! { + // Coalesced helpers: previously three separate `quote!` invocations + // and a `Vec` accumulator were stitched together with + // `#(#helper_tokens)*`. We instead build the conditional Clone / + // Deserialize sub-blocks as their own `TokenStream`s and splice + // them into a single `quote!`, producing the same emitted Rust code + // with one accumulator allocation removed. + let clone_impl = if has_derive(&dto_struct, "Clone") { + quote! {} + } else { + quote! { impl Clone for #dto_ident { fn clone(&self) -> Self { Self { @@ -293,11 +307,13 @@ fn maybe_generate_same_file_relation_override( } } } - }); - } + } + }; - if !has_derive(&dto_struct, "Deserialize") { - helper_tokens.push(quote! { + let deserialize_impl = if has_derive(&dto_struct, "Deserialize") { + quote! {} + } else { + quote! { #[derive(serde::Deserialize)] #(#dto_serde_attrs)* struct #proxy_ident { @@ -315,12 +331,15 @@ fn maybe_generate_same_file_relation_override( }) } } - }); - } + } + }; + + let helpers = quote! { + #clone_impl + #deserialize_impl - helper_tokens.push(quote! { - impl From<#model_ty> for #dto_ident { - fn from(source: #model_ty) -> Self { + impl From<#model_ty> for #dto_ident { + fn from(source: #model_ty) -> Self { Self { #(#from_model_assignments),* } @@ -338,12 +357,9 @@ fn maybe_generate_same_file_relation_override( Self(source.map(Into::into)) } } - }); + }; - Ok(Some(( - quote! { #wrapper_ident }, - quote! { #(#helper_tokens)* }, - ))) + Ok(Some((quote! { #wrapper_ident }, helpers))) } /// Generate schema code from a struct with optional field filtering diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 86a19a3..6d03f03 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -173,7 +173,17 @@ pub fn generate_and_write_openapi( } } - // Pretty-print for user-visible files + // NOTE on F-01: an earlier audit suggested serialising the + // `OpenApi` document once into `serde_json::Value` and emitting + // pretty + compact from the cached `Value`. We deliberately do + // **not** do that here. Going through `Value` re-orders every + // object's keys alphabetically (because the default + // `serde_json::Map` is `BTreeMap`-backed), which silently changes + // the field order in every user-visible `openapi.json` file. The + // marginal build-time saving is not worth churning the output of a + // file users diff in CI. Keep two direct serialisations. + // + // Pretty-print for user-visible files. if !input.openapi_file_names.is_empty() { let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?; for openapi_file_name in &input.openapi_file_names { @@ -189,7 +199,7 @@ pub fn generate_and_write_openapi( } } - // Compact JSON for embedding (smaller binary, faster downstream compilation) + // Compact JSON for embedding (smaller binary, faster downstream compilation). let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() { Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?) } else { @@ -227,10 +237,11 @@ pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { 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() - && let Ok(contents) = std::fs::read_to_string(&cargo_toml) + // Check if this is a workspace root (has Cargo.toml with [workspace]). + // `read_to_string` already fails when the file does not exist, so the + // previous `.exists()` pre-flight is redundant — drop it to save one + // stat per iteration of the walk. + if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) && contents.contains("[workspace]") { return dir.join("target"); @@ -262,23 +273,27 @@ fn merge_route_storage_data(metadata: &mut CollectedMetadata, route_storage: &[S return; } - for route in &mut metadata.routes { - // Find matching StoredRouteInfo by function name - let mut matches = route_storage - .iter() - .filter(|s| s.fn_name == route.function_name); + // Build `fn_name -> Option<&StoredRouteInfo>` index in a single pass: + // `Some(_)` when the name is unique, `None` when it is ambiguous + // (appears more than once). This turns the previous O(N*M) nested + // scan into O(N + M). + let mut stored_index: HashMap<&str, Option<&StoredRouteInfo>> = + HashMap::with_capacity(route_storage.len()); + for stored in route_storage { + stored_index + .entry(stored.fn_name.as_str()) + .and_modify(|slot| *slot = None) + .or_insert(Some(stored)); + } - let Some(stored) = matches.next() else { + for route in &mut metadata.routes { + // Skip if no match or ambiguous (multiple routes share fn_name). + let Some(Some(stored)) = stored_index.get(route.function_name.as_str()) else { continue; }; - // Skip if ambiguous (multiple routes with same function name) - if matches.next().is_some() { - continue; - } - - // Supplement with ROUTE_STORAGE data - // Only override when ROUTE_STORAGE has an explicit value + // Supplement with ROUTE_STORAGE data — only override when an + // explicit value is present. if let Some(ref tags) = stored.tags { route.tags = Some(tags.clone()); } diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index b02a45c..38c6e44 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } -sea-orm = { version = "^2.0.0-rc.37", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } +sea-orm = { version = "^2.0.0-rc.38", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } uuid = { version = "1", features = ["v4", "serde"] } tempfile = "3" diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index bc03522..bc295a3 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2564,6 +2564,38 @@ "tags" ] }, + "DocumentMetadata": { + "type": "object", + "properties": { + "charCount": { + "type": "integer", + "minimum": 0 + }, + "classificationLevel": { + "type": "integer", + "format": "uint8" + }, + "estimatedPages": { + "type": "integer", + "minimum": 0 + }, + "retentionYears": { + "type": "integer", + "format": "uint32" + }, + "wordCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "wordCount", + "charCount", + "estimatedPages", + "classificationLevel", + "retentionYears" + ] + }, "Enum": { "type": "string", "enum": [ @@ -4246,6 +4278,116 @@ "name", "createdAt" ] + }, + "ValidateDocumentRequest": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "classification": { + "type": "string" + }, + "content": { + "type": "string" + }, + "department": { + "type": "string" + }, + "documentType": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "expiryDate": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string" + } + }, + "required": [ + "documentType", + "title", + "content", + "author", + "department", + "classification", + "effectiveDate" + ] + }, + "ValidationError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "field", + "message" + ] + }, + "ValidationResult": { + "type": "object", + "properties": { + "documentId": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + }, + "metadata": { + "$ref": "#/components/schemas/DocumentMetadata" + }, + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationWarning" + } + } + }, + "required": [ + "valid", + "documentId", + "errors", + "warnings", + "metadata" + ] + }, + "ValidationWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "field", + "message" + ] } } }, diff --git a/openapi.json b/openapi.json index bc03522..bc295a3 100644 --- a/openapi.json +++ b/openapi.json @@ -2564,6 +2564,38 @@ "tags" ] }, + "DocumentMetadata": { + "type": "object", + "properties": { + "charCount": { + "type": "integer", + "minimum": 0 + }, + "classificationLevel": { + "type": "integer", + "format": "uint8" + }, + "estimatedPages": { + "type": "integer", + "minimum": 0 + }, + "retentionYears": { + "type": "integer", + "format": "uint32" + }, + "wordCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "wordCount", + "charCount", + "estimatedPages", + "classificationLevel", + "retentionYears" + ] + }, "Enum": { "type": "string", "enum": [ @@ -4246,6 +4278,116 @@ "name", "createdAt" ] + }, + "ValidateDocumentRequest": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "classification": { + "type": "string" + }, + "content": { + "type": "string" + }, + "department": { + "type": "string" + }, + "documentType": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "expiryDate": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string" + } + }, + "required": [ + "documentType", + "title", + "content", + "author", + "department", + "classification", + "effectiveDate" + ] + }, + "ValidationError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "field", + "message" + ] + }, + "ValidationResult": { + "type": "object", + "properties": { + "documentId": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + }, + "metadata": { + "$ref": "#/components/schemas/DocumentMetadata" + }, + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationWarning" + } + } + }, + "required": [ + "valid", + "documentId", + "errors", + "warnings", + "metadata" + ] + }, + "ValidationWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "field", + "message" + ] } } }, diff --git a/package.json b/package.json index 7102a3d..9ca538e 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,12 @@ "description": "**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum.", "author": "devfive", "devDependencies": { - "eslint-plugin-devup": "^2.0.18", - "oxlint": "^1.61.0", + "eslint-plugin-devup": "^2.0.19", + "oxlint": "^1.66.0", "husky": "^9.1", "bun-test-env-dom": "^1.0", "@devup-ui/bun-plugin": "^1.0", - "@types/bun": "^1.3", - "eslint": "9" + "@types/bun": "^1.3" }, "workspaces": [ "apps/*" @@ -29,4 +28,4 @@ "prepare": "husky", "changepacks": "bunx @changepacks/cli" } -} \ No newline at end of file +} From 463f512dc42b2adc1281ca2f6c5decd7a8fac3bd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 23 May 2026 01:16:12 +0900 Subject: [PATCH 2/8] Implement validate --- Cargo.lock | 52 ++ Cargo.toml | 4 + crates/vespera/Cargo.toml | 29 +- crates/vespera/src/__validation.rs | 24 + crates/vespera/src/lib.rs | 17 + crates/vespera/src/validated.rs | 143 ++++ crates/vespera/tests/derive_garde_emit.rs | 144 ++++ crates/vespera/tests/validated_extractor.rs | 406 +++++++++++ crates/vespera_macro/Cargo.toml | 10 + crates/vespera_macro/src/garde_emit.rs | 629 ++++++++++++++++++ crates/vespera_macro/src/lib.rs | 65 +- crates/vespera_macro/src/parser/mod.rs | 2 +- crates/vespera_macro/src/parser/schema/mod.rs | 1 + .../src/parser/schema/schema_attrs.rs | 456 +++++++++++++ .../src/parser/schema/struct_schema.rs | 275 ++++++++ crates/vespera_macro/src/schema_impl.rs | 10 +- examples/axum-example/openapi.json | 180 ++--- examples/axum-example/src/routes/mod.rs | 1 + examples/axum-example/src/routes/validated.rs | 54 ++ .../snapshots/integration_test__openapi.snap | 64 ++ openapi.json | 180 ++--- 21 files changed, 2479 insertions(+), 267 deletions(-) create mode 100644 crates/vespera/src/__validation.rs create mode 100644 crates/vespera/src/validated.rs create mode 100644 crates/vespera/tests/derive_garde_emit.rs create mode 100644 crates/vespera/tests/validated_extractor.rs create mode 100644 crates/vespera_macro/src/garde_emit.rs create mode 100644 crates/vespera_macro/src/parser/schema/schema_attrs.rs create mode 100644 examples/axum-example/src/routes/validated.rs diff --git a/Cargo.lock b/Cargo.lock index 11fe96b..5cdabd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.58" @@ -681,6 +690,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1267,6 +1290,32 @@ dependencies = [ "slab", ] +[[package]] +name = "garde" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a74b56a4039a46e8c91cc9d84e8a7df4e1f8b24239ca57d1304b3263cb599b9" +dependencies = [ + "compact_str", + "garde_derive", + "once_cell", + "regex", + "smallvec", + "url", +] + +[[package]] +name = "garde_derive" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7224c08ec489e2840af29ed882b47f7f6ac8f4ce15c275d9fc0d6d1b94578ae6" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3939,10 +3988,13 @@ dependencies = [ "axum", "axum-extra", "chrono", + "garde", + "serde", "serde_json", "tempfile", "tokio", "tokio-cron-scheduler", + "tower", "tower-layer", "tower-service", "vespera_core", diff --git a/Cargo.toml b/Cargo.toml index 73c187a..d54d296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,10 @@ vespera_core = { path = "crates/vespera_core", version = "0.1.51" } vespera_macro = { path = "crates/vespera_macro", version = "0.1.51" } vespera_inprocess = { path = "crates/vespera_inprocess", version = "0.1.51" } vespera_jni = { path = "crates/vespera_jni", version = "0.1.51" } +# Runtime validator backend. Held behind the `validation` feature on +# the `vespera` crate; users never name it directly — the proc-macro +# emits paths via `::vespera::__validation::garde::...`. +garde = { version = "0.22", default-features = false, features = ["email", "url", "pattern"] } [workspace.lints.clippy] all = { level = "warn", priority = -1 } diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 53a96a5..2ac6a0d 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -7,10 +7,25 @@ license.workspace = true repository.workspace = true [features] -default = ["axum-extra/typed-header", "axum-extra/form", "axum-extra/query", "axum-extra/multipart", "axum-extra/cookie"] +# `validation` is on by default — `#[derive(Schema)]` automatically emits +# `impl garde::Validate` blocks and the `Validated` extractor is +# available. Opt out with `default-features = false` if you need a +# leaner build without the `garde` runtime dependency. +default = [ + "axum-extra/typed-header", + "axum-extra/form", + "axum-extra/query", + "axum-extra/multipart", + "axum-extra/cookie", + "validation", +] cron = ["dep:tokio-cron-scheduler", "dep:tokio"] inprocess = ["dep:vespera_inprocess"] jni = ["inprocess", "dep:vespera_jni"] +# Runtime validation: `#[derive(Schema)]` additionally emits +# `impl garde::Validate` and the `Validated` extractor is enabled. +# The `garde` crate is bundled internally and never named by user code. +validation = ["dep:garde", "vespera_macro/validation"] [dependencies] vespera_core = { workspace = true } @@ -26,6 +41,18 @@ tokio-cron-scheduler = { version = "0.15", optional = true } tokio = { version = "1", features = ["rt"], optional = true } vespera_inprocess = { workspace = true, optional = true } vespera_jni = { workspace = true, optional = true } +# Hidden behind `validation` feature; re-exported via the private +# `vespera::__validation` module so the proc-macro can name it +# without forcing the user to add `garde` to their own Cargo.toml. +garde = { workspace = true, optional = true } + +[dev-dependencies] +# Used by integration tests under `tests/` that exercise the +# `Validated` extractor and the macro-emitted `garde::Validate` +# impls. Not pulled into the production build. +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tower = { version = "0.5", features = ["util"] } [lints] workspace = true diff --git a/crates/vespera/src/__validation.rs b/crates/vespera/src/__validation.rs new file mode 100644 index 0000000..825f943 --- /dev/null +++ b/crates/vespera/src/__validation.rs @@ -0,0 +1,24 @@ +//! Private re-export module used by `vespera_macro`-generated code. +//! +//! When the `validation` feature is enabled, `#[derive(vespera::Schema)]` +//! emits an `impl ::vespera::__validation::garde::Validate` block. The +//! impl body calls `::vespera::__validation::garde::rules::*::apply` and +//! constructs paths via `::vespera::__validation::garde::util::nested_path!`. +//! +//! Going through this `__validation` facade keeps two guarantees: +//! +//! 1. **Users never name `garde`.** A single +//! `vespera = { features = ["validation"] }` is all the user needs; +//! the macro never produces `::garde::...` paths in user code, so the +//! user's `Cargo.toml` doesn't need a `garde` dependency. +//! +//! 2. **Reversibility.** Should we ever swap the runtime validator (or +//! build our own), only this module changes — the emitted call sites +//! stay the same. Macro expansions in user crates are insulated from +//! the swap. +//! +//! This module is `pub` because the macro must emit absolute paths +//! through it, but it lives behind `#[doc(hidden)]` and is not part of +//! the stable surface. External callers must use [`crate::Validated`]. + +pub use ::garde; diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index 7793278..6c3e22b 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -122,6 +122,23 @@ where pub use tower_layer; pub use tower_service; +/// Runtime validation — private re-export of `garde` used by the +/// `#[derive(Schema)]` codegen. Users never reference this module +/// directly; it exists so the macro-emitted impl bodies stay inside the +/// `vespera` namespace and so we retain the freedom to swap the +/// validator backend later without touching user code. +#[cfg(feature = "validation")] +#[doc(hidden)] +pub mod __validation; + +/// [`Validated`] extractor — wraps any axum extractor and runs +/// `garde::Validate` on the inner payload before the handler is called. +/// Failure produces `422 Unprocessable Entity` with a JSON error envelope. +#[cfg(feature = "validation")] +mod validated; +#[cfg(feature = "validation")] +pub use validated::{ValidatePayload, Validated}; + /// In-process dispatch — drive an axum Router without a TCP socket. #[cfg(feature = "inprocess")] pub use vespera_inprocess as inprocess; diff --git a/crates/vespera/src/validated.rs b/crates/vespera/src/validated.rs new file mode 100644 index 0000000..c447f31 --- /dev/null +++ b/crates/vespera/src/validated.rs @@ -0,0 +1,143 @@ +//! `Validated` extractor — wraps any axum `FromRequest` extractor and +//! runs the inner type's [`garde::Validate`] impl before handing the +//! value to the handler. +//! +//! ```ignore +//! use vespera::{Validated, Schema, axum::Json}; +//! +//! #[derive(serde::Deserialize, Schema)] +//! struct CreateUser { +//! #[schema(min_length = 3, max_length = 32)] +//! username: String, +//! } +//! +//! async fn create(Validated(Json(req)): Validated>) +//! -> &'static str +//! { +//! // `req` has already passed validation. +//! "ok" +//! } +//! ``` +//! +//! On validation failure the rejection is `422 Unprocessable Entity` +//! with a JSON body of shape: +//! +//! ```json +//! { "errors": [ { "path": "username", "message": "..." }, ... ] } +//! ``` + +use ::axum::{ + Json, + extract::{FromRequest, Request}, + http::{StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use ::garde::Validate; + +/// Extractor wrapper that validates the inner extractor's output via +/// [`garde::Validate`] before handing it to the handler. +/// +/// `T` is typically `axum::Json` / `axum::Form` / +/// `axum::extract::Query` where `U: serde::Deserialize + +/// garde::Validate`. +#[derive(Debug, Clone, Copy)] +pub struct Validated(pub T); + +/// Helper trait that pulls the validatable payload out of common axum +/// extractors so `Validated>` can call `U::validate(&u, &())`. +pub trait ValidatePayload { + /// The inner type that implements [`garde::Validate`]. + type Inner: Validate; + /// Borrow the inner value for validation. + fn payload(&self) -> &Self::Inner; +} + +impl ValidatePayload for Json +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayload for ::axum::Form +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayload for ::axum::extract::Query +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayload for ::axum::extract::Path +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl FromRequest for Validated +where + S: Send + Sync, + T: FromRequest + ValidatePayload + Send, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + let extracted = T::from_request(req, state) + .await + .map_err(IntoResponse::into_response)?; + match extracted.payload().validate() { + Ok(()) => Ok(Self(extracted)), + Err(report) => Err(build_validation_response(&report)), + } + } +} + +/// Build the canonical `422 Unprocessable Entity` response from a +/// [`garde::Report`]. +/// +/// Body shape: +/// ```json +/// { "errors": [ { "path": "field.name", "message": "..." } ] } +/// ``` +/// +/// We build the JSON via `serde_json::json!` (no extra `serde` derive +/// dep needed) so this module compiles with the bare `serde_json` +/// re-export already present on the `vespera` crate. +fn build_validation_response(report: &::garde::Report) -> Response { + let errors: Vec<::serde_json::Value> = report + .iter() + .map(|(path, err)| { + ::serde_json::json!({ + "path": path.to_string(), + "message": err.message(), + }) + }) + .collect(); + let envelope = ::serde_json::json!({ "errors": errors }); + let body = envelope.to_string(); + + let mut response = (StatusCode::UNPROCESSABLE_ENTITY, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + "application/json".parse().expect("static value parses"), + ); + response +} diff --git a/crates/vespera/tests/derive_garde_emit.rs b/crates/vespera/tests/derive_garde_emit.rs new file mode 100644 index 0000000..bd11d8a --- /dev/null +++ b/crates/vespera/tests/derive_garde_emit.rs @@ -0,0 +1,144 @@ +//! End-to-end consumer-side test: `#[derive(vespera::Schema)]` with +//! `#[schema(...)]` constraints must produce a working +//! `garde::Validate` impl that rejects bad values and accepts good ones. +//! +//! This is the integration counterpart to the unit tests in +//! `vespera_macro::garde_emit::tests` — the unit tests verify the +//! emitted token-stream *shape*, this file verifies it *actually +//! compiles and runs* against the real garde crate at user-build time. + +#![cfg(feature = "validation")] + +use ::vespera::__validation::garde::Validate; +use ::vespera::Schema; + +#[derive(Schema, serde::Deserialize)] +#[allow(dead_code)] +struct CreateUser { + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z0-9_]+$")] + username: String, + + #[schema(format = "email")] + email: String, + + #[schema(minimum = 0, maximum = 150)] + age: u32, + + #[schema(min_items = 1, max_items = 5)] + tags: Vec, + + #[schema(min_length = 8)] + nickname: Option, +} + +fn fixture(overrides: impl FnOnce(&mut CreateUser)) -> CreateUser { + let mut u = CreateUser { + username: "alice".to_owned(), + email: "alice@example.com".to_owned(), + age: 30, + tags: vec!["a".to_owned()], + nickname: None, + }; + overrides(&mut u); + u +} + +#[test] +fn valid_payload_passes_validation() { + let u = fixture(|_| {}); + assert!( + u.validate().is_ok(), + "fixture should pass: {:?}", + u.validate().unwrap_err() + ); +} + +#[test] +fn min_length_violation_is_reported_with_field_path() { + let u = fixture(|u| u.username = "ab".to_owned()); // 2 < min_length 3 + let report = u.validate().expect_err("validation should fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!( + paths.iter().any(|p| p == "username"), + "expected `username` in error paths, got {paths:?}" + ); +} + +#[test] +fn max_length_violation_is_reported() { + let u = fixture(|u| u.username = "a".repeat(33)); + let report = u.validate().expect_err("validation should fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!(paths.iter().any(|p| p == "username"), "got {paths:?}"); +} + +#[test] +fn pattern_violation_is_reported() { + // Uppercase chars violate `^[a-z0-9_]+$`. + let u = fixture(|u| u.username = "Alice".to_owned()); + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "username")); +} + +#[test] +fn format_email_violation_is_reported() { + let u = fixture(|u| u.email = "not-an-email".to_owned()); + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "email")); +} + +#[test] +fn range_violation_is_reported_on_numeric_field() { + let u = fixture(|u| u.age = 999); + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "age")); +} + +#[test] +fn vec_min_items_violation_is_reported() { + let u = fixture(|u| u.tags.clear()); + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "tags")); +} + +#[test] +fn vec_max_items_violation_is_reported() { + let u = fixture(|u| { + u.tags = (0..10).map(|i| format!("tag{i}")).collect(); + }); + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "tags")); +} + +#[test] +fn option_field_validates_only_when_present() { + // None — skipped entirely. + let u = fixture(|u| u.nickname = None); + assert!(u.validate().is_ok()); + + // Some(too short) — fails. + let u = fixture(|u| u.nickname = Some("hi".to_owned())); // 2 < min_length 8 + let report = u.validate().expect_err("validation should fail"); + assert!(report.iter().any(|(p, _)| p.to_string() == "nickname")); + + // Some(long enough) — passes. + let u = fixture(|u| u.nickname = Some("longnickname".to_owned())); + assert!(u.validate().is_ok()); +} + +#[test] +fn multiple_field_violations_all_reported_in_one_report() { + let u = fixture(|u| { + u.username = "X".to_owned(); // pattern + min_length + u.email = "broken".to_owned(); // format + u.age = 200; // range + }); + let report = u.validate().expect_err("validation should fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!(paths.iter().any(|p| p == "username")); + assert!(paths.iter().any(|p| p == "email")); + assert!(paths.iter().any(|p| p == "age")); + // At least 3 errors collected — exact count may vary because + // username triggers both pattern and (implicitly satisfied) length. + assert!(report.iter().count() >= 3, "got {paths:?}"); +} diff --git a/crates/vespera/tests/validated_extractor.rs b/crates/vespera/tests/validated_extractor.rs new file mode 100644 index 0000000..210d8ba --- /dev/null +++ b/crates/vespera/tests/validated_extractor.rs @@ -0,0 +1,406 @@ +//! End-to-end test: `Validated>` axum extractor rejects invalid +//! payloads with `422 Unprocessable Entity` + a JSON error envelope, and +//! lets valid payloads through to the handler. + +#![cfg(feature = "validation")] + +use ::axum::{Router, body::Body, http::Request, routing::post}; +use ::serde::Deserialize; +use ::tower::ServiceExt; +use ::vespera::{Schema, Validated}; + +#[derive(Deserialize, Schema)] +#[allow(dead_code)] +struct CreatePost { + #[schema(min_length = 3, max_length = 50)] + title: String, + + #[schema(min_length = 1)] + content: String, +} + +async fn create_post( + Validated(::axum::Json(_payload)): Validated<::axum::Json>, +) -> &'static str { + "ok" +} + +fn router() -> Router { + Router::new().route("/posts", post(create_post)) +} + +async fn body_to_string(body: Body) -> String { + let bytes = ::axum::body::to_bytes(body, usize::MAX).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +#[tokio::test] +async fn valid_payload_returns_200() { + let app = router(); + let req = Request::builder() + .method("POST") + .uri("/posts") + .header("content-type", "application/json") + .body(Body::from( + r#"{"title":"My Post","content":"hello world"}"#, + )) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(body_to_string(res.into_body()).await, "ok"); +} + +#[tokio::test] +async fn short_title_returns_422_with_path_keyed_envelope() { + let app = router(); + let req = Request::builder() + .method("POST") + .uri("/posts") + .header("content-type", "application/json") + .body(Body::from(r#"{"title":"X","content":"ok"}"#)) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + assert_eq!( + res.headers() + .get("content-type") + .map(|v| v.to_str().unwrap()), + Some("application/json"), + ); + + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); + + let errors = body["errors"].as_array().expect("errors array missing"); + assert!(!errors.is_empty(), "errors array is empty"); + assert!( + errors + .iter() + .any(|e| e["path"].as_str() == Some("title") + && e["message"].as_str().is_some()), + "expected an error with path=\"title\", got {body:#}" + ); +} + +#[tokio::test] +async fn empty_content_returns_422() { + let app = router(); + let req = Request::builder() + .method("POST") + .uri("/posts") + .header("content-type", "application/json") + .body(Body::from(r#"{"title":"Valid title","content":""}"#)) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); + let errors = body["errors"].as_array().unwrap(); + assert!(errors.iter().any(|e| e["path"].as_str() == Some("content"))); +} + +#[tokio::test] +async fn multiple_violations_all_appear_in_envelope() { + let app = router(); + let req = Request::builder() + .method("POST") + .uri("/posts") + .header("content-type", "application/json") + .body(Body::from(r#"{"title":"X","content":""}"#)) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); + let errors = body["errors"].as_array().unwrap(); + let paths: Vec<&str> = errors + .iter() + .filter_map(|e| e["path"].as_str()) + .collect(); + assert!(paths.contains(&"title"), "got {paths:?}"); + assert!(paths.contains(&"content"), "got {paths:?}"); +} + +#[tokio::test] +async fn malformed_json_propagates_400_not_422() { + // When the inner extractor itself fails (e.g. invalid JSON), + // `Validated` must forward that rejection unchanged rather than + // synthesizing a 422 from a non-existent garde report. + let app = router(); + let req = Request::builder() + .method("POST") + .uri("/posts") + .header("content-type", "application/json") + .body(Body::from("not json")) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + // Axum's Json extractor returns 400 (or 415 depending on cause) — + // anything that is NOT our 422 envelope is acceptable here. + assert_ne!(res.status(), 422); +} + +// ── per-rule 422 coverage ──────────────────────────────────────────── +// +// `CreatePost` only exercises `min_length` / `max_length`. The model +// below pulls in every other rule we emit so each runs through the +// full extractor → garde → 422 envelope flow at least once. + +#[derive(Deserialize, Schema)] +#[allow(dead_code)] +struct AllRules { + /// String length + pattern + format. + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z0-9_]+$")] + username: String, + + /// `format = "email"` → garde `email::apply`. + #[schema(format = "email")] + email: String, + + /// `format = "uri"` → garde `url::apply`. + #[schema(format = "uri")] + homepage: String, + + /// `format = "ipv4"` → garde `ip::apply(IpKind::V4)`. + #[schema(format = "ipv4")] + addr_v4: String, + + /// `format = "ipv6"` → garde `ip::apply(IpKind::V6)`. + #[schema(format = "ipv6")] + addr_v6: String, + + /// Numeric range. + #[schema(minimum = 0, maximum = 150)] + age: u32, + + /// `Vec` length + uniqueness annotation (uniqueness itself is + /// OpenAPI-only — no garde rule). + #[schema(min_items = 1, max_items = 3, unique_items)] + tags: Vec, + + /// `Option` field — should validate only when `Some`. + #[schema(min_length = 8)] + nickname: Option, +} + +async fn create_all_rules( + Validated(::axum::Json(_p)): Validated<::axum::Json>, +) -> &'static str { + "ok" +} + +fn all_rules_router() -> Router { + Router::new().route("/all", post(create_all_rules)) +} + +fn good_payload() -> ::serde_json::Value { + ::serde_json::json!({ + "username": "alice_99", + "email": "alice@example.com", + "homepage": "https://alice.example.com", + "addr_v4": "192.168.0.1", + "addr_v6": "::1", + "age": 30, + "tags": ["a", "b"], + "nickname": null + }) +} + +/// Send `payload` to `/all` and decode the response as +/// `(status, body_json)`. Asserts `application/json` content-type when +/// the status is `422` (the canonical validation envelope). +async fn dispatch( + app: Router, + payload: ::serde_json::Value, +) -> (u16, ::serde_json::Value) { + let req = Request::builder() + .method("POST") + .uri("/all") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + let status = res.status().as_u16(); + if status == 422 { + assert_eq!( + res.headers().get("content-type").map(|v| v.to_str().unwrap()), + Some("application/json"), + ); + } + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await) + .unwrap_or(::serde_json::Value::Null); + (status, body) +} + +/// Assert that `body` is the 422 envelope and contains at least one +/// error whose `path == field`. +fn assert_envelope_has_field_error(body: &::serde_json::Value, field: &str) { + let errors = body["errors"] + .as_array() + .unwrap_or_else(|| panic!("missing `errors` array in {body:#}")); + assert!( + errors + .iter() + .any(|e| e["path"].as_str() == Some(field) + && e["message"].as_str().is_some()), + "expected an error with path=\"{field}\" + message, got {body:#}", + ); +} + +#[tokio::test] +async fn all_rules_happy_path_returns_200() { + let (status, _) = dispatch(all_rules_router(), good_payload()).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn rule_pattern_violation_returns_422() { + let mut bad = good_payload(); + bad["username"] = ::serde_json::json!("Alice99"); // uppercase fails `^[a-z0-9_]+$` + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "username"); +} + +#[tokio::test] +async fn rule_format_email_violation_returns_422() { + let mut bad = good_payload(); + bad["email"] = ::serde_json::json!("not-an-email"); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "email"); +} + +#[tokio::test] +async fn rule_format_uri_violation_returns_422() { + let mut bad = good_payload(); + bad["homepage"] = ::serde_json::json!("not a url"); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "homepage"); +} + +#[tokio::test] +async fn rule_format_ipv4_violation_returns_422() { + let mut bad = good_payload(); + bad["addr_v4"] = ::serde_json::json!("999.999.999.999"); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "addr_v4"); +} + +#[tokio::test] +async fn rule_format_ipv6_violation_returns_422() { + let mut bad = good_payload(); + bad["addr_v6"] = ::serde_json::json!("not-an-ipv6-address"); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "addr_v6"); +} + +#[tokio::test] +async fn rule_range_minimum_violation_returns_422() { + // `age` lives in `u32`; the only sub-`minimum=0` value JSON can + // express against a `u32` is via serde rejecting -1. To exercise + // the `range::apply` rule itself we use a type that allows a value + // below the schema minimum on a fresh struct. + #[derive(Deserialize, Schema)] + #[allow(dead_code)] + struct Signed { + #[schema(minimum = 0, maximum = 150)] + age: i32, + } + async fn handler( + Validated(::axum::Json(_)): Validated<::axum::Json>, + ) -> &'static str { + "ok" + } + let app = Router::new().route("/n", post(handler)); + let req = Request::builder() + .method("POST") + .uri("/n") + .header("content-type", "application/json") + .body(Body::from(r#"{"age":-1}"#)) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); + assert_envelope_has_field_error(&body, "age"); +} + +#[tokio::test] +async fn rule_range_maximum_violation_returns_422() { + let mut bad = good_payload(); + bad["age"] = ::serde_json::json!(9999); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "age"); +} + +#[tokio::test] +async fn rule_min_items_violation_returns_422() { + let mut bad = good_payload(); + bad["tags"] = ::serde_json::json!([]); // empty Vec < min_items=1 + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "tags"); +} + +#[tokio::test] +async fn rule_max_items_violation_returns_422() { + let mut bad = good_payload(); + bad["tags"] = ::serde_json::json!(["a", "b", "c", "d"]); // 4 > max_items=3 + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "tags"); +} + +#[tokio::test] +async fn rule_option_field_validates_only_when_some_returns_422() { + let mut bad = good_payload(); + bad["nickname"] = ::serde_json::json!("hi"); // 2 chars < min_length=8 + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + assert_envelope_has_field_error(&body, "nickname"); +} + +#[tokio::test] +async fn rule_option_field_none_skips_validation() { + // `nickname: null` must not contribute a 422 — Option validates + // only when `Some`. The rest of the payload is valid, so we + // expect a clean 200. + let mut p = good_payload(); + p["nickname"] = ::serde_json::Value::Null; + let (status, _) = dispatch(all_rules_router(), p).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn multiple_per_rule_violations_all_appear_in_envelope() { + let bad = ::serde_json::json!({ + "username": "BAD!", // pattern + (length OK at 4) + "email": "broken", // format=email + "homepage": "broken", // format=uri + "addr_v4": "999.999.999.999", // format=ipv4 + "addr_v6": "broken", // format=ipv6 + "age": 9999, // range + "tags": [], // min_items + "nickname": "x" // Option's min_length + }); + let (status, body) = dispatch(all_rules_router(), bad).await; + assert_eq!(status, 422); + for field in [ + "username", "email", "homepage", "addr_v4", "addr_v6", "age", "tags", + "nickname", + ] { + assert_envelope_has_field_error(&body, field); + } +} diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index b150fea..a0534ac 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -9,6 +9,16 @@ repository.workspace = true [lib] proc-macro = true +[features] +# When enabled, `#[derive(Schema)]` additionally emits an +# `impl ::vespera::__validation::garde::Validate` block that wires the +# field-level `#[schema(min_length=..., pattern=..., minimum=..., ...)]` +# constraints into garde's runtime validators. The proc-macro itself +# does NOT depend on `garde` — it only emits token streams that reference +# the path; the user's `vespera = { features = ["validation"] }` is what +# actually pulls in the runtime crate. +validation = [] + [dependencies] quote = "1" syn = { version = "2", features = ["full"] } diff --git a/crates/vespera_macro/src/garde_emit.rs b/crates/vespera_macro/src/garde_emit.rs new file mode 100644 index 0000000..054d05a --- /dev/null +++ b/crates/vespera_macro/src/garde_emit.rs @@ -0,0 +1,629 @@ +//! Code generation for `impl ::vespera::__validation::garde::Validate`. +//! +//! When the `validation` feature is enabled on `vespera_macro`, +//! `#[derive(Schema)]` calls [`emit_garde_validate`] to produce a +//! token stream containing the `Validate` trait implementation. The +//! generated code references garde indirectly via the facade module +//! `::vespera::__validation::garde::...` so user crates never need to +//! depend on `garde` directly. +//! +//! ## Limitations (v1) +//! +//! - **Enums**: no `Validate` impl is emitted. +//! - **Generic / lifetime-parameterised structs**: if the struct +//! carries any constraints and also any generic parameter, the macro +//! emits a `compile_error!` rather than guessing at trait bounds. +//! - **Tuple / unit structs**: no `Validate` impl is emitted. +//! - **`format = "uuid"`**: produces an OpenAPI annotation only; garde +//! has no built-in UUID validator, and we don't synthesise one. +//! - **`exclusive_minimum` / `exclusive_maximum`**: OpenAPI annotation +//! only; garde's `range` rule is inclusive on both sides. +//! - **`multiple_of`**: OpenAPI annotation only; no garde counterpart. +//! - **`unique_items`**: OpenAPI annotation only. + +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type}; + +use crate::parser::schema::schema_attrs::{SchemaConstraints, extract_schema_constraints}; + +/// Public entry point used by `process_derive_schema`. +/// +/// When `validation` is **off** on `vespera_macro`, this expands to an +/// empty stub via the `#[cfg(...)]` switch at the bottom of this file. +#[cfg(feature = "validation")] +#[must_use] +pub fn emit_garde_validate(input: &DeriveInput) -> TokenStream { + emit_impl(input) +} + +#[cfg(not(feature = "validation"))] +#[must_use] +pub fn emit_garde_validate(_input: &DeriveInput) -> TokenStream { + TokenStream::new() +} + +#[cfg(feature = "validation")] +fn emit_impl(input: &DeriveInput) -> TokenStream { + // Only structs with named fields are validated; everything else + // produces an empty token stream so the derive remains a no-op. + let Data::Struct(data_struct) = &input.data else { + return TokenStream::new(); + }; + let Fields::Named(fields_named) = &data_struct.fields else { + return TokenStream::new(); + }; + + // Collect per-field constraints up-front so we can short-circuit + // when nothing on the struct opts into validation. + let per_field: Vec<(&syn::Field, SchemaConstraints)> = fields_named + .named + .iter() + .map(|f| (f, extract_schema_constraints(&f.attrs))) + .collect(); + + if per_field.iter().all(|(_, c)| !c.has_runtime_rule()) { + // No field requested a runtime rule — skip Validate emission. + // OpenAPI annotation-only constraints (example / read_only / + // write_only / unique_items / exclusive bounds / multiple_of / + // format=uuid) still made it into the schema via the OpenAPI + // path; they just don't need a garde impl. + return TokenStream::new(); + } + + // Bail with a clear compile error for generic types — supporting + // them properly would require synthesising `where` bounds based on + // which generic parameters appear in validated field types. Out + // of scope for v1. + if !input.generics.params.is_empty() { + let msg = format!( + "vespera::Schema validation does not yet support generic / \ + lifetime-parameterised types (struct `{}`). Move the \ + `#[schema(...)]` constraints to a non-generic wrapper, or \ + open an issue if you need this.", + input.ident, + ); + return quote! { ::std::compile_error!(#msg); }; + } + + let struct_ident = &input.ident; + let field_idents: Vec<&syn::Ident> = fields_named + .named + .iter() + .filter_map(|f| f.ident.as_ref()) + .collect(); + + let field_blocks: Vec = per_field + .iter() + .filter_map(|(field, constraints)| { + let ident = field.ident.as_ref()?; + emit_field_block(ident, &field.ty, constraints) + }) + .collect(); + + if field_blocks.is_empty() { + return TokenStream::new(); + } + + quote! { + #[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_variables, + unused_mut, + unused_parens, + non_upper_case_globals, + )] + impl ::vespera::__validation::garde::Validate for #struct_ident { + type Context = (); + + fn validate_into( + &self, + __garde_user_ctx: &Self::Context, + mut __garde_path: &mut dyn ::core::ops::FnMut() -> ::vespera::__validation::garde::Path, + __garde_report: &mut ::vespera::__validation::garde::Report, + ) { + let _ = __garde_user_ctx; // suppress unused warning when no `custom` rules + let Self { #(#field_idents),* } = self; + #(#field_blocks)* + } + } + } +} + +#[cfg(feature = "validation")] +fn emit_field_block( + field_ident: &syn::Ident, + field_ty: &Type, + c: &SchemaConstraints, +) -> Option { + if !c.has_runtime_rule() { + return None; + } + + let field_name_str = field_ident.to_string(); + let numeric_kind = rust_numeric_kind(peel_option(field_ty).unwrap_or(field_ty)); + let rule_blocks = emit_rule_blocks(c, &field_name_str, numeric_kind.as_deref()); + if rule_blocks.is_empty() { + return None; + } + + let block = if is_option_type(field_ty) { + // `field_ident` is `&Option` after the `let Self { .. } = self` destructure. + // Match ergonomics make `inner` end up as `&T`. + quote! { + { + let mut __garde_path = ::vespera::__validation::garde::util::nested_path!( + __garde_path, #field_name_str + ); + if let ::std::option::Option::Some(__garde_binding) = #field_ident { + #rule_blocks + } + } + } + } else { + quote! { + { + let mut __garde_path = ::vespera::__validation::garde::util::nested_path!( + __garde_path, #field_name_str + ); + let __garde_binding = &*#field_ident; + #rule_blocks + } + } + }; + + Some(block) +} + +#[cfg(feature = "validation")] +#[allow(clippy::too_many_lines)] // exhaustive rule-to-emit dispatcher +fn emit_rule_blocks( + c: &SchemaConstraints, + field_name: &str, + numeric_kind: Option<&str>, +) -> TokenStream { + let mut blocks: Vec = Vec::new(); + + // ── String length (min_length / max_length → length::chars) ─────── + if c.min_length.is_some() || c.max_length.is_some() { + let min = c.min_length.unwrap_or(0); + let max = c.max_length.unwrap_or(usize::MAX); + blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::length::chars::apply)( + &*__garde_binding, + (#min, #max), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }); + } + + // ── Array length (min_items / max_items → length::simple) ───────── + if c.min_items.is_some() || c.max_items.is_some() { + let min = c.min_items.unwrap_or(0); + let max = c.max_items.unwrap_or(usize::MAX); + blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::length::simple::apply)( + &*__garde_binding, + (#min, #max), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }); + } + + // ── Numeric range (minimum / maximum → range::apply) ────────────── + if c.minimum.is_some() || c.maximum.is_some() { + let min_expr = numeric_some(c.minimum, numeric_kind); + let max_expr = numeric_some(c.maximum, numeric_kind); + blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::range::apply)( + __garde_binding, + (#min_expr, #max_expr), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }); + } + + // ── Pattern (pattern = "..." → static LazyLock) ──────────── + if let Some(pattern) = &c.pattern { + let static_ident = format_ident!( + "__VESPERA_PATTERN_{}", + field_name.to_ascii_uppercase() + ); + blocks.push(quote! { + { + static #static_ident: ::std::sync::LazyLock< + ::vespera::__validation::garde::rules::pattern::regex::Regex, + > = ::std::sync::LazyLock::new(|| { + ::vespera::__validation::garde::rules::pattern::regex::Regex::new(#pattern) + .expect("regex literal validated at vespera::Schema derive time") + }); + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::pattern::apply)( + &*__garde_binding, + (&*#static_ident,), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + } + }); + } + + // ── Format-driven rules (email / uri / ipv4 / ipv6 / ip) ────────── + if let Some(fmt) = c.format.as_deref() { + match fmt { + "email" => blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::email::apply)( + &*__garde_binding, + (), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }), + "uri" | "url" => blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::url::apply)( + &*__garde_binding, + (), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }), + "ipv4" => blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::ip::apply)( + &*__garde_binding, + (::vespera::__validation::garde::rules::ip::IpKind::V4,), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }), + "ipv6" => blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::ip::apply)( + &*__garde_binding, + (::vespera::__validation::garde::rules::ip::IpKind::V6,), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }), + "ip" => blocks.push(quote! { + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::ip::apply)( + &*__garde_binding, + (::vespera::__validation::garde::rules::ip::IpKind::Any,), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } + }), + // "uuid" / "date" / "date-time" / "byte" / "binary" / + // "password" / "hostname" / "regex" → OpenAPI annotation + // only; no garde counterpart. Silently skip. + _ => {} + } + } + + quote! { #(#blocks)* } +} + +// ── helpers ────────────────────────────────────────────────────────── + +#[cfg(feature = "validation")] +fn numeric_some(value: Option, numeric_kind: Option<&str>) -> TokenStream { + let Some(v) = value else { + return quote! { ::std::option::Option::None }; + }; + + // Render the literal in a form that matches the field type so the + // garde `range::apply` typeck succeeds. + numeric_kind.map_or_else( + // Unknown numeric kind — last-resort `as _` and let the user + // see a compiler error pointing at their field type. + || quote! { ::std::option::Option::Some(#v as _) }, + |kind| { + let ty_ident = syn::Ident::new(kind, Span::call_site()); + let is_float = matches!(kind, "f32" | "f64"); + if !is_float && v.fract() == 0.0 && v.is_finite() { + // Convert via i64 first so negative literals survive the + // round-trip; the trailing `as #ty_ident` puts it into the + // exact integer type garde's range::apply needs. + #[allow(clippy::cast_possible_truncation)] + let i = v as i64; + quote! { ::std::option::Option::Some(#i as #ty_ident) } + } else { + quote! { ::std::option::Option::Some(#v as #ty_ident) } + } + }, + ) +} + +#[cfg(feature = "validation")] +fn is_option_type(ty: &Type) -> bool { + let Type::Path(tp) = ty else { + return false; + }; + tp.path + .segments + .last() + .is_some_and(|seg| seg.ident == "Option") +} + +#[cfg(feature = "validation")] +fn peel_option(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { + return None; + }; + let last = tp.path.segments.last()?; + if last.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(t) => Some(t), + _ => None, + }) +} + +#[cfg(feature = "validation")] +fn rust_numeric_kind(ty: &Type) -> Option { + let Type::Path(tp) = ty else { + return None; + }; + let last = tp.path.segments.last()?; + let name = last.ident.to_string(); + matches!( + name.as_str(), + "i8" | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "f32" + | "f64" + ) + .then_some(name) +} + +// ── tests ──────────────────────────────────────────────────────────── + +#[cfg(all(test, feature = "validation"))] +mod tests { + use super::*; + use syn::parse_quote; + + #[allow(clippy::needless_pass_by_value)] // test helper takes owned input by convention + fn emit_to_string(input: DeriveInput) -> String { + emit_garde_validate(&input).to_string() + } + + #[test] + fn no_constraints_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct User { + pub name: String, + pub age: i32, + } + }; + assert!(emit_to_string(s).is_empty()); + } + + #[test] + fn min_length_only_emits_length_chars_apply() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3)] + pub name: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for User")); + assert!(out.contains("length :: chars :: apply")); + assert!(out.contains("3usize") || out.contains("3 usize")); + } + + #[test] + fn min_and_max_length_combined_in_single_call() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3, max_length = 32)] + pub name: String, + } + }; + let out = emit_to_string(s); + // single length::chars::apply call carrying both bounds + let occurrences = out.matches("length :: chars :: apply").count(); + assert_eq!(occurrences, 1); + } + + #[test] + fn range_emit_uses_field_numeric_type() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(minimum = 0, maximum = 150)] + pub age: u32, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!(out.contains("as u32")); + } + + #[test] + fn range_emit_on_float_field_keeps_decimal_point() { + let s: DeriveInput = parse_quote! { + struct Price { + #[schema(minimum = 0.01, maximum = 99.99)] + pub amount: f64, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!(out.contains("as f64")); + } + + #[test] + fn pattern_emits_static_lazy_lock_regex() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(pattern = "^[a-z]+$")] + pub username: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("static __VESPERA_PATTERN_USERNAME")); + assert!(out.contains("LazyLock")); + assert!(out.contains("regex :: Regex :: new")); + assert!(out.contains("pattern :: apply")); + } + + #[test] + fn format_email_emits_email_apply() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(format = "email")] + pub email: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("email :: apply")); + } + + #[test] + fn format_uri_emits_url_apply() { + let s: DeriveInput = parse_quote! { + struct Site { + #[schema(format = "uri")] + pub home: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("url :: apply")); + } + + #[test] + fn format_ipv4_emits_ip_apply_with_v4_kind() { + let s: DeriveInput = parse_quote! { + struct Host { + #[schema(format = "ipv4")] + pub addr: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("ip :: apply")); + assert!(out.contains("IpKind :: V4")); + } + + #[test] + fn format_uuid_is_annotation_only_no_runtime_rule() { + let s: DeriveInput = parse_quote! { + struct Entity { + #[schema(format = "uuid")] + pub id: String, + } + }; + // uuid alone has no garde rule → no Validate impl emitted. + assert!(emit_to_string(s).is_empty()); + } + + #[test] + fn option_field_wraps_rule_block_in_if_let_some() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3)] + pub nickname: Option, + } + }; + let out = emit_to_string(s); + assert!(out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("length :: chars :: apply")); + } + + #[test] + fn min_max_items_on_vec_emits_length_simple() { + let s: DeriveInput = parse_quote! { + struct Post { + #[schema(min_items = 1, max_items = 5)] + pub tags: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: simple :: apply")); + } + + #[test] + fn enum_emits_nothing() { + let e: DeriveInput = parse_quote! { + enum Status { Active, Inactive } + }; + assert!(emit_to_string(e).is_empty()); + } + + #[test] + fn tuple_struct_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct Wrapper(pub String); + }; + assert!(emit_to_string(s).is_empty()); + } + + #[test] + fn unit_struct_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct Empty; + }; + assert!(emit_to_string(s).is_empty()); + } + + #[test] + fn generic_struct_with_constraints_produces_compile_error() { + let s: DeriveInput = parse_quote! { + struct Wrapper { + #[schema(min_length = 3)] + pub name: String, + pub inner: T, + } + }; + let out = emit_to_string(s); + assert!(out.contains("compile_error")); + assert!(out.contains("generic")); + } + + #[test] + fn annotation_only_constraints_emit_nothing() { + // example / read_only / write_only / unique_items / multiple_of / + // exclusive bounds are OpenAPI annotations only; they should not + // drag a Validate impl into existence on their own. + let s: DeriveInput = parse_quote! { + struct Doc { + #[schema(read_only, example = "abc", unique_items, multiple_of = 0.5)] + pub id: String, + } + }; + assert!(emit_to_string(s).is_empty()); + } +} diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index ce6c980..1989b21 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -45,6 +45,7 @@ mod collector; mod cron_impl; mod error; mod file_utils; +mod garde_emit; mod http; mod metadata; mod method; @@ -102,16 +103,53 @@ pub fn cron(attr: TokenStream, item: TokenStream) -> TokenStream { /// Derive macro for Schema /// /// Supports `#[schema(name = "CustomName")]` attribute to set custom `OpenAPI` schema name. +/// +/// # Duplicate schema name detection +/// +/// `SCHEMA_STORAGE` is keyed by the OpenAPI schema name (struct ident by +/// default, or `#[schema(name = "...")]` if specified). When two +/// **different** struct definitions register under the same name, only +/// the last one would survive in `openapi.json` — a silent footgun +/// that has bitten real users. This derive therefore checks the +/// storage before inserting and emits a `compile_error!` so the +/// conflict surfaces at build time instead of at spec-generation time. +/// +/// Identical re-registrations (e.g. incremental rebuilds running the +/// same derive twice) are idempotent: the definition token-stream +/// matches and the second call is a no-op. #[cfg(not(tarpaulin_include))] #[proc_macro_derive(Schema, attributes(schema, serde))] pub fn derive_schema(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); let (metadata, expanded) = schema_impl::process_derive_schema(&input); let name = metadata.name.clone(); - SCHEMA_STORAGE + + let mut storage = SCHEMA_STORAGE .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(name, metadata); + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Some(existing) = storage.get(&name) + && existing.definition != metadata.definition + { + // Two distinct struct definitions both ask for the same + // OpenAPI schema name. Surface this as a hard compile error + // — the alternative (silent last-write-wins overwrite) hides + // schemas from the generated `openapi.json` in a way that is + // only discovered by inspecting the spec. + let span = input.ident.span(); + let msg = format!( + "duplicate vespera Schema name `{name}` -- two different struct \ + definitions both register under the same OpenAPI schema name. \ + The later definition would silently overwrite the earlier one \ + in the generated `openapi.json`. Rename one of the structs, or \ + annotate one with `#[schema(name = \"OtherName\")]` to give \ + them distinct OpenAPI names." + ); + let err = syn::Error::new(span, msg).to_compile_error(); + return TokenStream::from(err); + } + + storage.insert(name, metadata); TokenStream::from(expanded) } @@ -259,6 +297,7 @@ pub fn schema(input: TokenStream) -> TokenStream { #[proc_macro] pub fn schema_type(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as schema_macro::SchemaTypeInput); + let ignore_schema = input.ignore_schema; // Get stored schemas and generate code let (tokens, generated_metadata) = { @@ -271,9 +310,23 @@ pub fn schema_type(input: TokenStream) -> TokenStream { } }; - // If custom name is provided, register the schema directly - // This ensures it appears in OpenAPI even when `ignore` is set - if let Some(metadata) = generated_metadata { + // The emitted token stream contains a struct with + // `#[derive(Schema)]`; that derive macro registers the schema into + // `SCHEMA_STORAGE` on its own. We only need to pre-register here + // when `ignore_schema` is set, because in that case the emitted + // struct does NOT carry `#[derive(Schema)]` and would otherwise + // be invisible to the OpenAPI generator. + // + // Pre-registering in the non-ignore path would cause the + // duplicate-name check in `derive_schema` to fire on every + // `schema_type!` call — the macro's own pre-insert collides with + // the derive's later insert because the two `StructMetadata` + // definitions are textually different (the pre-registered one is + // synthesised by `schema_macro`; the derive-emitted one is the + // expanded struct token stream). + if ignore_schema + && let Some(metadata) = generated_metadata + { let name = metadata.name.clone(); SCHEMA_STORAGE .lock() diff --git a/crates/vespera_macro/src/parser/mod.rs b/crates/vespera_macro/src/parser/mod.rs index 20eb38d..ae11fce 100644 --- a/crates/vespera_macro/src/parser/mod.rs +++ b/crates/vespera_macro/src/parser/mod.rs @@ -4,7 +4,7 @@ mod parameters; mod path; mod request_body; mod response; -mod schema; +pub mod schema; pub use operation::build_operation_from_function; pub use schema::{ extract_default, extract_field_rename, extract_rename_all, extract_skip, diff --git a/crates/vespera_macro/src/parser/schema/mod.rs b/crates/vespera_macro/src/parser/schema/mod.rs index 6dd9daa..55990ad 100644 --- a/crates/vespera_macro/src/parser/schema/mod.rs +++ b/crates/vespera_macro/src/parser/schema/mod.rs @@ -31,6 +31,7 @@ mod enum_schema; mod generics; +pub mod schema_attrs; mod serde_attrs; mod struct_schema; mod type_schema; diff --git a/crates/vespera_macro/src/parser/schema/schema_attrs.rs b/crates/vespera_macro/src/parser/schema/schema_attrs.rs new file mode 100644 index 0000000..d745df4 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/schema_attrs.rs @@ -0,0 +1,456 @@ +//! Parser for the field-level `#[schema(...)]` attribute constraints. +//! +//! Unlike the struct-level `#[schema(name=..., ref=..., nullable)]` parsers in +//! [`super::serde_attrs`], this module reads the *validation* keys that may +//! appear on individual fields: +//! +//! ```ignore +//! #[derive(vespera::Schema)] +//! pub struct CreateUser { +//! #[schema(min_length = 3, max_length = 32, pattern = "^[a-z]+$")] +//! pub username: String, +//! +//! #[schema(minimum = 0, maximum = 150)] +//! pub age: u32, +//! +//! #[schema(format = "email")] +//! pub email: String, +//! +//! #[schema(read_only, example = "abc-123")] +//! pub id: String, +//! } +//! ``` +//! +//! The extracted [`SchemaConstraints`] flow into two different consumers: +//! +//! 1. **OpenAPI emission** (`struct_schema::parse_struct_to_schema`): the +//! constraints are merged into the per-field `Schema` literal so that +//! `openapi.json` exposes `minLength`, `maxLength`, `pattern`, … on the +//! field schemas. +//! 2. **`garde::Validate` emission** (`schema_impl::process_derive_schema`, +//! behind the `validation` feature): the same constraints are translated +//! into `garde::rules::*::apply` calls inside the generated `validate_into` +//! method body. +//! +//! Keys that have no garde counterpart (`example`, `read_only`, `write_only`, +//! `unique_items`) are still parsed — they only affect OpenAPI output. + +use syn::{Attribute, Expr, ExprLit, Lit}; + +/// Field-level validation / documentation constraints carried by +/// `#[schema(...)]`. +/// +/// Every field is `Option<_>` so an unset key means "no constraint". The +/// shape mirrors the corresponding fields on +/// [`vespera_core::schema::Schema`] one-for-one — keep them in sync. +#[derive(Default, Clone, Debug, PartialEq)] +pub struct SchemaConstraints { + // ── string / array length ──────────────────────────────────────── + pub min_length: Option, + pub max_length: Option, + pub pattern: Option, + + // ── numeric range ──────────────────────────────────────────────── + pub minimum: Option, + pub maximum: Option, + pub exclusive_minimum: Option, + pub exclusive_maximum: Option, + pub multiple_of: Option, + + // ── array constraints ──────────────────────────────────────────── + pub min_items: Option, + pub max_items: Option, + pub unique_items: Option, + + // ── OpenAPI annotations (no runtime validation) ────────────────── + pub format: Option, + pub example: Option, + pub read_only: Option, + pub write_only: Option, +} + +impl SchemaConstraints { + /// `true` when no constraint keys were present on the field. + #[must_use] + pub fn is_empty(&self) -> bool { + self.min_length.is_none() + && self.max_length.is_none() + && self.pattern.is_none() + && self.minimum.is_none() + && self.maximum.is_none() + && self.exclusive_minimum.is_none() + && self.exclusive_maximum.is_none() + && self.multiple_of.is_none() + && self.min_items.is_none() + && self.max_items.is_none() + && self.unique_items.is_none() + && self.format.is_none() + && self.example.is_none() + && self.read_only.is_none() + && self.write_only.is_none() + } + + /// `true` when at least one constraint produces a `garde` runtime rule + /// (excludes pure-OpenAPI annotations such as `example` / `read_only` / + /// `write_only` / `unique_items`). + #[must_use] + pub fn has_runtime_rule(&self) -> bool { + self.min_length.is_some() + || self.max_length.is_some() + || self.pattern.is_some() + || self.minimum.is_some() + || self.maximum.is_some() + || self.exclusive_minimum.is_some() + || self.exclusive_maximum.is_some() + || self.multiple_of.is_some() + || self.min_items.is_some() + || self.max_items.is_some() + || matches!( + self.format.as_deref(), + Some("email" | "uri" | "url" | "ipv4" | "ipv6" | "ip") + ) + } +} + +/// Extract all field-level `#[schema(...)]` validation / documentation +/// constraints from `attrs`. +/// +/// Unknown keys are **silently ignored** so that struct-level keys +/// (`name`, `ref`, `nullable`) and future additions don't break this +/// parser when it walks a struct-level `#[schema(...)]` attribute. +#[must_use] +pub fn extract_schema_constraints(attrs: &[Attribute]) -> SchemaConstraints { + let mut out = SchemaConstraints::default(); + for attr in attrs { + if !attr.path().is_ident("schema") { + continue; + } + let _ = attr.parse_nested_meta(|meta| { + // ── string / array length ──────────────────────────────── + if meta.path.is_ident("min_length") { + out.min_length = Some(parse_usize(&meta)?); + } else if meta.path.is_ident("max_length") { + out.max_length = Some(parse_usize(&meta)?); + } else if meta.path.is_ident("pattern") { + out.pattern = Some(parse_str(&meta)?); + } + // ── numeric range ──────────────────────────────────────── + else if meta.path.is_ident("minimum") { + out.minimum = Some(parse_f64(&meta)?); + } else if meta.path.is_ident("maximum") { + out.maximum = Some(parse_f64(&meta)?); + } else if meta.path.is_ident("exclusive_minimum") { + out.exclusive_minimum = Some(parse_bool_or_default_true(&meta)?); + } else if meta.path.is_ident("exclusive_maximum") { + out.exclusive_maximum = Some(parse_bool_or_default_true(&meta)?); + } else if meta.path.is_ident("multiple_of") { + out.multiple_of = Some(parse_f64(&meta)?); + } + // ── array constraints ──────────────────────────────────── + else if meta.path.is_ident("min_items") { + out.min_items = Some(parse_usize(&meta)?); + } else if meta.path.is_ident("max_items") { + out.max_items = Some(parse_usize(&meta)?); + } else if meta.path.is_ident("unique_items") { + out.unique_items = Some(parse_bool_or_default_true(&meta)?); + } + // ── OpenAPI annotations ────────────────────────────────── + else if meta.path.is_ident("format") { + out.format = Some(parse_str(&meta)?); + } else if meta.path.is_ident("example") { + out.example = Some(parse_example_value(&meta)?); + } else if meta.path.is_ident("read_only") { + out.read_only = Some(parse_bool_or_default_true(&meta)?); + } else if meta.path.is_ident("write_only") { + out.write_only = Some(parse_bool_or_default_true(&meta)?); + } else { + // Unknown key — could be a struct-level key like `name`, + // `ref`, `nullable`, `default` that lives on the same + // `#[schema(...)]` attribute. Consume any `= value` + // payload so `parse_nested_meta` doesn't fail at the + // trailing comma. + if let Ok(value) = meta.value() { + let _: syn::Expr = value.parse()?; + } + } + Ok(()) + }); + } + out +} + +// ── primitive value helpers ────────────────────────────────────────── + +fn parse_usize(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + let lit: syn::LitInt = meta.value()?.parse()?; + lit.base10_parse::() +} + +fn parse_f64(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + let value = meta.value()?; + let expr: Expr = value.parse()?; + match expr { + Expr::Lit(ExprLit { + lit: Lit::Float(f), .. + }) => f.base10_parse::(), + Expr::Lit(ExprLit { + lit: Lit::Int(i), .. + }) => i.base10_parse::(), + // Allow `minimum = -5` etc. — negation parses as a unary expression. + Expr::Unary(unary) => { + if let syn::UnOp::Neg(_) = unary.op + && let Expr::Lit(ExprLit { lit, .. }) = *unary.expr + { + let positive = match lit { + Lit::Float(f) => f.base10_parse::()?, + Lit::Int(i) => i.base10_parse::()?, + other => { + return Err(syn::Error::new_spanned( + other, + "expected a numeric literal after `-`", + )); + } + }; + return Ok(-positive); + } + Err(syn::Error::new_spanned(unary, "expected a numeric literal")) + } + other => Err(syn::Error::new_spanned( + other, + "expected a numeric literal (int or float)", + )), + } +} + +fn parse_str(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + let lit: syn::LitStr = meta.value()?.parse()?; + Ok(lit.value()) +} + +/// Parse a boolean attribute that may also appear as a bare keyword. +/// +/// `#[schema(read_only)]` → `true` +/// `#[schema(read_only = true)]` → `true` +/// `#[schema(read_only = false)]` → `false` +fn parse_bool_or_default_true(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + // Try to parse a value; if there is no `=` after the key, fall back to + // `true` (bare-keyword form). + let Ok(value) = meta.value() else { + return Ok(true); + }; + let lit: syn::LitBool = value.parse()?; + Ok(lit.value) +} + +/// Parse an `example = ...` value into a `serde_json::Value`. +/// +/// Accepts string / integer / float / boolean literals, and `null`. More +/// complex shapes (objects, arrays) are not supported in attribute form — +/// users wanting structured examples should populate `example` programmatically +/// or via `#[schema(default = "...")]` which is already handled elsewhere. +fn parse_example_value(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + let value = meta.value()?; + let expr: Expr = value.parse()?; + expr_to_json_value(&expr) +} + +fn expr_to_json_value(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { lit, .. }) => lit_to_json_value(lit), + Expr::Unary(unary) => { + if let syn::UnOp::Neg(_) = unary.op + && let Expr::Lit(ExprLit { lit, .. }) = unary.expr.as_ref() + { + let positive = lit_to_json_value(lit)?; + // Try integer first so that `example = -5` round-trips + // as `serde_json::json!(-5)` (i64) and not as the + // semantically equal but type-distinct `-5.0` (f64). + if let Some(i) = positive.as_i64() { + return Ok(serde_json::json!(-i)); + } + if let Some(n) = positive.as_f64() { + return Ok(serde_json::json!(-n)); + } + } + Err(syn::Error::new_spanned( + expr, + "expected a literal after `-`", + )) + } + Expr::Path(path) if path.path.is_ident("null") => Ok(serde_json::Value::Null), + other => Err(syn::Error::new_spanned( + other, + "expected a literal value (string / int / float / bool / null)", + )), + } +} + +fn lit_to_json_value(lit: &Lit) -> syn::Result { + match lit { + Lit::Str(s) => Ok(serde_json::Value::String(s.value())), + Lit::Bool(b) => Ok(serde_json::Value::Bool(b.value)), + Lit::Int(i) => Ok(serde_json::json!(i.base10_parse::()?)), + Lit::Float(f) => Ok(serde_json::json!(f.base10_parse::()?)), + other => Err(syn::Error::new_spanned( + other, + "unsupported literal type for `example`", + )), + } +} + +// ── tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + fn parse(attrs: &[Attribute]) -> SchemaConstraints { + extract_schema_constraints(attrs) + } + + #[test] + fn empty_attrs_produce_empty_constraints() { + let c = parse(&[]); + assert!(c.is_empty()); + assert!(!c.has_runtime_rule()); + } + + #[test] + fn unrelated_attrs_are_ignored() { + let c = parse(&[parse_quote!(#[serde(rename = "x")])]); + assert!(c.is_empty()); + } + + #[test] + fn struct_level_keys_are_ignored() { + // `name`, `ref`, `nullable`, `default` are handled by other parsers; + // this parser must walk the same `#[schema(...)]` attribute without + // tripping on them. + let c = parse(&[parse_quote!(#[schema(name = "Foo", nullable)])]); + assert!(c.is_empty()); + } + + #[test] + fn min_max_length_int_literals() { + let c = parse(&[parse_quote!(#[schema(min_length = 3, max_length = 64)])]); + assert_eq!(c.min_length, Some(3)); + assert_eq!(c.max_length, Some(64)); + assert!(c.has_runtime_rule()); + } + + #[test] + fn pattern_str_literal() { + let c = parse(&[parse_quote!(#[schema(pattern = "^[a-z]+$")])]); + assert_eq!(c.pattern.as_deref(), Some("^[a-z]+$")); + } + + #[test] + fn minimum_maximum_accept_both_int_and_float() { + let c1 = parse(&[parse_quote!(#[schema(minimum = 0, maximum = 150)])]); + assert_eq!(c1.minimum, Some(0.0)); + assert_eq!(c1.maximum, Some(150.0)); + let c2 = parse(&[parse_quote!(#[schema(minimum = 0.5, maximum = 99.9)])]); + assert_eq!(c2.minimum, Some(0.5)); + assert_eq!(c2.maximum, Some(99.9)); + } + + #[test] + fn negative_minimum() { + let c = parse(&[parse_quote!(#[schema(minimum = -10)])]); + assert_eq!(c.minimum, Some(-10.0)); + } + + #[test] + fn exclusive_bounds_default_to_true_when_bare() { + let c = parse(&[parse_quote!(#[schema(exclusive_minimum, exclusive_maximum)])]); + assert_eq!(c.exclusive_minimum, Some(true)); + assert_eq!(c.exclusive_maximum, Some(true)); + } + + #[test] + fn exclusive_bounds_explicit_false() { + let c = parse(&[parse_quote!(#[schema(exclusive_minimum = false)])]); + assert_eq!(c.exclusive_minimum, Some(false)); + } + + #[test] + fn multiple_of_float() { + let c = parse(&[parse_quote!(#[schema(multiple_of = 0.25)])]); + assert_eq!(c.multiple_of, Some(0.25)); + } + + #[test] + fn min_max_items_with_unique() { + let c = parse(&[parse_quote!(#[schema(min_items = 1, max_items = 5, unique_items)])]); + assert_eq!(c.min_items, Some(1)); + assert_eq!(c.max_items, Some(5)); + assert_eq!(c.unique_items, Some(true)); + } + + #[test] + fn format_strings() { + let c = parse(&[parse_quote!(#[schema(format = "email")])]); + assert_eq!(c.format.as_deref(), Some("email")); + assert!(c.has_runtime_rule()); + + let c2 = parse(&[parse_quote!(#[schema(format = "uuid")])]); + // uuid has no garde rule — annotation only, no runtime rule. + assert!(!c2.has_runtime_rule()); + } + + #[test] + fn example_with_various_literal_kinds() { + let c = parse(&[parse_quote!(#[schema(example = "hello")])]); + assert_eq!(c.example, Some(serde_json::json!("hello"))); + + let c = parse(&[parse_quote!(#[schema(example = 42)])]); + assert_eq!(c.example, Some(serde_json::json!(42))); + + let c = parse(&[parse_quote!(#[schema(example = 2.5)])]); + assert_eq!(c.example, Some(serde_json::json!(2.5))); + + let c = parse(&[parse_quote!(#[schema(example = true)])]); + assert_eq!(c.example, Some(serde_json::json!(true))); + + let c = parse(&[parse_quote!(#[schema(example = -5)])]); + assert_eq!(c.example, Some(serde_json::json!(-5))); + } + + #[test] + fn read_only_write_only_bare_and_explicit() { + let c = parse(&[parse_quote!(#[schema(read_only, write_only = false)])]); + assert_eq!(c.read_only, Some(true)); + assert_eq!(c.write_only, Some(false)); + } + + #[test] + fn mixed_struct_and_field_keys_in_one_attr_are_partitioned_correctly() { + // A user might write a single `#[schema(name = "...", min_length = 3)]`. + // The struct-level `name` is ignored here; the field-level + // `min_length` is parsed. + let c = parse(&[parse_quote!(#[schema(name = "MyType", min_length = 3)])]); + assert!(c.name_unaffected()); + assert_eq!(c.min_length, Some(3)); + } + + #[test] + fn multiple_schema_attrs_accumulate() { + let attrs: [Attribute; 2] = [ + parse_quote!(#[schema(min_length = 3)]), + parse_quote!(#[schema(max_length = 32, format = "email")]), + ]; + let c = parse(&attrs); + assert_eq!(c.min_length, Some(3)); + assert_eq!(c.max_length, Some(32)); + assert_eq!(c.format.as_deref(), Some("email")); + } + + impl SchemaConstraints { + // helper for the partitioning test above — kept private to the + // tests module so it doesn't pollute the public surface. + fn name_unaffected(&self) -> bool { + self.format.is_none() && self.example.is_none() && self.read_only.is_none() + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index 8785a38..aa9dd71 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -9,6 +9,7 @@ use syn::{Fields, Type}; use vespera_core::schema::{Schema, SchemaRef, SchemaType}; use super::{ + schema_attrs::{SchemaConstraints, extract_schema_constraints}, serde_attrs::{ extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all, extract_schema_ref_override, extract_skip, extract_transparent, rename_field, @@ -143,6 +144,18 @@ pub fn parse_struct_to_schema( } } + // Extract field-level `#[schema(min_length=..., pattern=..., + // minimum=..., format=..., example=..., read_only, ...)]` + // constraints and merge them into the field schema. When + // the field references a component schema via `$ref`, we + // promote it to an `allOf` wrapper (mirroring the + // description-on-ref pattern above) so the constraints can + // sit alongside the reference. + let constraints = extract_schema_constraints(&field.attrs); + if !constraints.is_empty() { + apply_constraints_to_schema_ref(&mut schema_ref, &constraints); + } + // Required is determined solely by nullability (Option). // Fields with #[serde(default)] still have defaults applied in // openapi_generator, but that does NOT affect required status. @@ -215,6 +228,82 @@ pub fn parse_struct_to_schema( } } +/// Merge field-level `#[schema(...)]` constraints into the field's +/// `SchemaRef`. For `Inline` variants the constraints are written +/// directly onto the inner `Schema`; for `Ref` variants we promote to an +/// `allOf` wrapper so the constraints can sit alongside `$ref`. +fn apply_constraints_to_schema_ref(schema_ref: &mut SchemaRef, c: &SchemaConstraints) { + match schema_ref { + SchemaRef::Inline(schema) => apply_constraints(schema, c), + SchemaRef::Ref(_) => { + // mem::replace lets us move the Ref out without leaving an + // invalid value behind; the placeholder is overwritten + // before the function returns. + let taken = std::mem::replace( + schema_ref, + SchemaRef::Inline(Box::new(Schema::object())), + ); + if let SchemaRef::Ref(reference) = taken { + let mut wrapper = Schema { + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + }; + apply_constraints(&mut wrapper, c); + *schema_ref = SchemaRef::Inline(Box::new(wrapper)); + } + } + } +} + +/// Apply each set constraint to the corresponding `Schema` field. +fn apply_constraints(schema: &mut Schema, c: &SchemaConstraints) { + if let Some(v) = c.min_length { + schema.min_length = Some(v); + } + if let Some(v) = c.max_length { + schema.max_length = Some(v); + } + if let Some(ref v) = c.pattern { + schema.pattern = Some(v.clone()); + } + if let Some(v) = c.minimum { + schema.minimum = Some(v); + } + if let Some(v) = c.maximum { + schema.maximum = Some(v); + } + if let Some(v) = c.exclusive_minimum { + schema.exclusive_minimum = Some(v); + } + if let Some(v) = c.exclusive_maximum { + schema.exclusive_maximum = Some(v); + } + if let Some(v) = c.multiple_of { + schema.multiple_of = Some(v); + } + if let Some(v) = c.min_items { + schema.min_items = Some(v); + } + if let Some(v) = c.max_items { + schema.max_items = Some(v); + } + if let Some(v) = c.unique_items { + schema.unique_items = Some(v); + } + if let Some(ref v) = c.format { + schema.format = Some(v.clone()); + } + if let Some(ref v) = c.example { + schema.example = Some(v.clone()); + } + if let Some(v) = c.read_only { + schema.read_only = Some(v); + } + if let Some(v) = c.write_only { + schema.write_only = Some(v); + } +} + #[cfg(test)] mod tests { use rstest::rstest; @@ -597,4 +686,190 @@ mod tests { assert!(schema.properties.is_none()); assert!(schema.all_of.is_none()); } + + // ── field-level `#[schema(...)]` constraint propagation ───────── + + fn field_schema<'a>(schema: &'a Schema, field: &str) -> &'a Schema { + let props = schema.properties.as_ref().expect("properties missing"); + let entry = props.get(field).expect("field missing"); + match entry { + SchemaRef::Inline(boxed) => boxed.as_ref(), + SchemaRef::Ref(_) => panic!("expected inline schema for field '{field}'"), + } + } + + #[test] + fn schema_constraints_min_max_length_and_pattern_on_string_field() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct CreateUser { + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z]+$")] + username: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "username"); + assert_eq!(field.min_length, Some(3)); + assert_eq!(field.max_length, Some(32)); + assert_eq!(field.pattern.as_deref(), Some("^[a-z]+$")); + } + + #[test] + fn schema_constraints_minimum_maximum_on_numeric_field() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Profile { + #[schema(minimum = 0, maximum = 150)] + age: u32, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "age"); + assert_eq!(field.minimum, Some(0.0)); + assert_eq!(field.maximum, Some(150.0)); + } + + #[test] + fn schema_constraints_format_email_on_string_field() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct Contact { + #[schema(format = "email")] + email: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "email"); + assert_eq!(field.format.as_deref(), Some("email")); + } + + #[test] + fn schema_constraints_read_only_write_only_example() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct User { + #[schema(read_only, example = "abc-123")] + id: String, + #[schema(write_only)] + password: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let id_field = field_schema(&schema, "id"); + assert_eq!(id_field.read_only, Some(true)); + assert_eq!(id_field.example, Some(serde_json::json!("abc-123"))); + let pw_field = field_schema(&schema, "password"); + assert_eq!(pw_field.write_only, Some(true)); + } + + #[test] + fn schema_constraints_min_max_items_unique_on_vec_field() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Post { + #[schema(min_items = 1, max_items = 5, unique_items)] + tags: Vec, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "tags"); + assert_eq!(field.min_items, Some(1)); + assert_eq!(field.max_items, Some(5)); + assert_eq!(field.unique_items, Some(true)); + } + + #[test] + fn schema_constraints_exclusive_bounds_and_multiple_of() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Price { + #[schema(minimum = 0, exclusive_minimum, multiple_of = 0.01)] + amount: f64, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "amount"); + assert_eq!(field.minimum, Some(0.0)); + assert_eq!(field.exclusive_minimum, Some(true)); + assert_eq!(field.multiple_of, Some(0.01)); + } + + #[test] + fn schema_constraints_on_ref_field_promote_to_allof_wrapper() { + // A field referencing a known component schema must keep its + // `$ref` but gain the constraints via an `allOf` wrapper so the + // OpenAPI consumer still sees the reference. + let mut known = HashSet::new(); + known.insert("Address".to_string()); + let s: syn::ItemStruct = syn::parse_str( + r" + struct Order { + #[schema(read_only)] + shipping: Address, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &known, &HashMap::new()); + let field = field_schema(&schema, "shipping"); + assert_eq!(field.read_only, Some(true)); + let all_of = field.all_of.as_ref().expect("allOf wrap missing"); + assert_eq!(all_of.len(), 1); + assert!(matches!(all_of[0], SchemaRef::Ref(_))); + } + + #[test] + fn schema_constraints_coexist_with_doc_comment_on_ref_field() { + // When BOTH a doc comment AND constraints are present on a + // `$ref` field, the doc comment converts it to allOf first, then + // constraints are layered onto the same wrapper. + let mut known = HashSet::new(); + known.insert("Address".to_string()); + let s: syn::ItemStruct = syn::parse_str( + r" + struct Order { + /// Shipping address — must be present. + #[schema(read_only, write_only = false)] + shipping: Address, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &known, &HashMap::new()); + let field = field_schema(&schema, "shipping"); + assert!(field.description.is_some(), "doc comment lost"); + assert_eq!(field.read_only, Some(true)); + assert_eq!(field.write_only, Some(false)); + assert!(field.all_of.is_some(), "allOf wrap lost"); + } + + #[test] + fn schema_constraints_unknown_keys_on_field_are_silently_ignored() { + // Struct-level keys (e.g. `name`) accidentally placed on a field + // attribute should not trip the parser nor produce constraints. + let s: syn::ItemStruct = syn::parse_str( + r#" + struct Account { + #[schema(name = "Stray", min_length = 4)] + pin: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); + let field = field_schema(&schema, "pin"); + assert_eq!(field.min_length, Some(4)); + } } diff --git a/crates/vespera_macro/src/schema_impl.rs b/crates/vespera_macro/src/schema_impl.rs index 81c6ec8..eb352d6 100644 --- a/crates/vespera_macro/src/schema_impl.rs +++ b/crates/vespera_macro/src/schema_impl.rs @@ -106,7 +106,15 @@ pub fn process_derive_schema( } } metadata.field_defaults = field_defaults; - (metadata, proc_macro2::TokenStream::new()) + + // When the `validation` feature is enabled on `vespera_macro`, + // additionally emit `impl ::vespera::__validation::garde::Validate + // for #StructName { ... }` so the field-level `#[schema(...)]` + // constraints carry runtime checks alongside their OpenAPI metadata. + // The emit function returns an empty `TokenStream` when no field + // requests a runtime rule or when the feature is off. + let expanded = crate::garde_emit::emit_garde_validate(input); + (metadata, expanded) } /// Extract default values from `#[serde(default = "fn_name")]` attributes diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index bc295a3..4ed91e1 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2010,6 +2010,27 @@ } } } + }, + "/validated/users": { + "post": { + "operationId": "create_validated_user", + "tags": [ + "validated" + ], + "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatedUserRequest" + } + } + } + } + } + } } }, "components": { @@ -2564,38 +2585,6 @@ "tags" ] }, - "DocumentMetadata": { - "type": "object", - "properties": { - "charCount": { - "type": "integer", - "minimum": 0 - }, - "classificationLevel": { - "type": "integer", - "format": "uint8" - }, - "estimatedPages": { - "type": "integer", - "minimum": 0 - }, - "retentionYears": { - "type": "integer", - "format": "uint32" - }, - "wordCount": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "wordCount", - "charCount", - "estimatedPages", - "classificationLevel", - "retentionYears" - ] - }, "Enum": { "type": "string", "enum": [ @@ -4279,114 +4268,44 @@ "createdAt" ] }, - "ValidateDocumentRequest": { + "ValidatedUserRequest": { "type": "object", + "description": "Validated request body for `POST /validated/users`.", "properties": { - "author": { - "type": "string" - }, - "classification": { - "type": "string" - }, - "content": { - "type": "string" - }, - "department": { - "type": "string" - }, - "documentType": { - "type": "string" - }, - "effectiveDate": { - "type": "string" + "age": { + "type": "integer", + "format": "uint32", + "description": "Display age (0–150).", + "minimum": 0, + "maximum": 150 }, - "expiryDate": { + "email": { "type": "string", - "nullable": true - }, - "title": { - "type": "string" - } - }, - "required": [ - "documentType", - "title", - "content", - "author", - "department", - "classification", - "effectiveDate" - ] - }, - "ValidationError": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "field": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "field", - "message" - ] - }, - "ValidationResult": { - "type": "object", - "properties": { - "documentId": { - "type": "string" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ValidationError" - } + "format": "email", + "description": "Primary contact email — validated at the format level." }, - "metadata": { - "$ref": "#/components/schemas/DocumentMetadata" - }, - "valid": { - "type": "boolean" - }, - "warnings": { + "tags": { "type": "array", + "description": "Arbitrary tag list, 1–5 items.", "items": { - "$ref": "#/components/schemas/ValidationWarning" - } - } - }, - "required": [ - "valid", - "documentId", - "errors", - "warnings", - "metadata" - ] - }, - "ValidationWarning": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "field": { - "type": "string" + "type": "string" + }, + "minItems": 1, + "maxItems": 5 }, - "message": { - "type": "string" + "username": { + "type": "string", + "description": "User-chosen handle.", + "minLength": 3, + "maxLength": 32, + "pattern": "^[a-z0-9_]+$" } }, "required": [ - "code", - "field", - "message" + "username", + "email", + "age", + "tags" ] } } @@ -4416,6 +4335,9 @@ { "name": "uuid_items" }, + { + "name": "validated" + }, { "name": "third" } diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index 7e919c9..c3d08df 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -23,6 +23,7 @@ pub mod typed_form; pub mod typed_header; pub mod users; pub mod uuid_items; +pub mod validated; /// Health check endpoint #[vespera::route(get)] diff --git a/examples/axum-example/src/routes/validated.rs b/examples/axum-example/src/routes/validated.rs new file mode 100644 index 0000000..07da234 --- /dev/null +++ b/examples/axum-example/src/routes/validated.rs @@ -0,0 +1,54 @@ +//! Demonstration of the `#[derive(Schema)]` validation feature. +//! +//! Field-level `#[schema(min_length=..., max_length=..., pattern=..., +//! format=..., minimum=..., maximum=..., min_items=..., max_items=...)]` +//! attributes drive **both** the OpenAPI metadata for `openapi.json` +//! **and** the runtime `garde::Validate` impl wired up by the +//! `vespera::Validated` extractor. +//! +//! Send a bad payload to this route to see the `422 Unprocessable +//! Entity + { "errors": [...] }` response shape; a good payload +//! returns `200 OK` with an echo of the validated request. +//! +//! NOTE: the type is named `ValidatedUserRequest` — *not* +//! `CreateUserRequest` — to avoid clashing with the existing +//! `schema_type!(CreateUserRequest from User, ...)` in +//! `routes/users.rs`. Two derives with the same struct identifier +//! both register into the global `SCHEMA_STORAGE` map and the later +//! one silently overrides the earlier one in the emitted +//! `openapi.json`. + +use serde::{Deserialize, Serialize}; +use vespera::axum::Json; +use vespera::{Schema, Validated}; + +/// Validated request body for `POST /validated/users`. +#[derive(Debug, Deserialize, Serialize, Schema)] +pub struct ValidatedUserRequest { + /// User-chosen handle. + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z0-9_]+$")] + pub username: String, + + /// Primary contact email — validated at the format level. + #[schema(format = "email")] + pub email: String, + + /// Display age (0–150). + #[schema(minimum = 0, maximum = 150)] + pub age: u32, + + /// Arbitrary tag list, 1–5 items. + #[schema(min_items = 1, max_items = 5)] + pub tags: Vec, +} + +/// Echo back the validated input. If the request body fails +/// validation, this handler never runs — the `Validated` extractor +/// returns a `422` before it is reached. +#[vespera::route(post, path = "/users", tags = ["validated"])] +pub async fn create_validated_user( + Validated(Json(req)): Validated>, +) -> Json { + Json(req) +} + diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index f6739f8..d3be57b 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -2015,6 +2015,27 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } } + }, + "/validated/users": { + "post": { + "operationId": "create_validated_user", + "tags": [ + "validated" + ], + "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatedUserRequest" + } + } + } + } + } + } } }, "components": { @@ -4251,6 +4272,46 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "name", "createdAt" ] + }, + "ValidatedUserRequest": { + "type": "object", + "description": "Validated request body for `POST /validated/users`.", + "properties": { + "age": { + "type": "integer", + "format": "uint32", + "description": "Display age (0–150).", + "minimum": 0, + "maximum": 150 + }, + "email": { + "type": "string", + "format": "email", + "description": "Primary contact email — validated at the format level." + }, + "tags": { + "type": "array", + "description": "Arbitrary tag list, 1–5 items.", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 5 + }, + "username": { + "type": "string", + "description": "User-chosen handle.", + "minLength": 3, + "maxLength": 32, + "pattern": "^[a-z0-9_]+$" + } + }, + "required": [ + "username", + "email", + "age", + "tags" + ] } } }, @@ -4279,6 +4340,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" { "name": "uuid_items" }, + { + "name": "validated" + }, { "name": "third" } diff --git a/openapi.json b/openapi.json index bc295a3..4ed91e1 100644 --- a/openapi.json +++ b/openapi.json @@ -2010,6 +2010,27 @@ } } } + }, + "/validated/users": { + "post": { + "operationId": "create_validated_user", + "tags": [ + "validated" + ], + "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatedUserRequest" + } + } + } + } + } + } } }, "components": { @@ -2564,38 +2585,6 @@ "tags" ] }, - "DocumentMetadata": { - "type": "object", - "properties": { - "charCount": { - "type": "integer", - "minimum": 0 - }, - "classificationLevel": { - "type": "integer", - "format": "uint8" - }, - "estimatedPages": { - "type": "integer", - "minimum": 0 - }, - "retentionYears": { - "type": "integer", - "format": "uint32" - }, - "wordCount": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "wordCount", - "charCount", - "estimatedPages", - "classificationLevel", - "retentionYears" - ] - }, "Enum": { "type": "string", "enum": [ @@ -4279,114 +4268,44 @@ "createdAt" ] }, - "ValidateDocumentRequest": { + "ValidatedUserRequest": { "type": "object", + "description": "Validated request body for `POST /validated/users`.", "properties": { - "author": { - "type": "string" - }, - "classification": { - "type": "string" - }, - "content": { - "type": "string" - }, - "department": { - "type": "string" - }, - "documentType": { - "type": "string" - }, - "effectiveDate": { - "type": "string" + "age": { + "type": "integer", + "format": "uint32", + "description": "Display age (0–150).", + "minimum": 0, + "maximum": 150 }, - "expiryDate": { + "email": { "type": "string", - "nullable": true - }, - "title": { - "type": "string" - } - }, - "required": [ - "documentType", - "title", - "content", - "author", - "department", - "classification", - "effectiveDate" - ] - }, - "ValidationError": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "field": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "field", - "message" - ] - }, - "ValidationResult": { - "type": "object", - "properties": { - "documentId": { - "type": "string" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ValidationError" - } + "format": "email", + "description": "Primary contact email — validated at the format level." }, - "metadata": { - "$ref": "#/components/schemas/DocumentMetadata" - }, - "valid": { - "type": "boolean" - }, - "warnings": { + "tags": { "type": "array", + "description": "Arbitrary tag list, 1–5 items.", "items": { - "$ref": "#/components/schemas/ValidationWarning" - } - } - }, - "required": [ - "valid", - "documentId", - "errors", - "warnings", - "metadata" - ] - }, - "ValidationWarning": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "field": { - "type": "string" + "type": "string" + }, + "minItems": 1, + "maxItems": 5 }, - "message": { - "type": "string" + "username": { + "type": "string", + "description": "User-chosen handle.", + "minLength": 3, + "maxLength": 32, + "pattern": "^[a-z0-9_]+$" } }, "required": [ - "code", - "field", - "message" + "username", + "email", + "age", + "tags" ] } } @@ -4416,6 +4335,9 @@ { "name": "uuid_items" }, + { + "name": "validated" + }, { "name": "third" } From 32b712a09ae8508901081a8a80c58c56a8f75ea7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 25 May 2026 16:22:09 +0900 Subject: [PATCH 3/8] feat: binary wire migration + multi-app routing + Spring autoconfigure Two milestone cycles accumulated: Cycle 1 - Binary wire migration: replace base64-in-JSON envelope with length-prefixed binary wire format ([u32 BE | UTF-8 JSON header | raw body]). Remove dispatch_from_json/parse_request/serialize_error; introduce dispatch_from_bytes. Java native String dispatch(String) -> byte[] dispatchBytes(byte[]). Eliminates ~33% wire overhead + base64 CPU. Cycle 2 - Multi-app + 6 follow-up waves: (A) measured benchmarks, (C) TypedMultipart integration tests, (E) 422 validation hoisting to wire header, (B) dispatch_from_bytes_async + JNI dispatchAsync (CompletableFuture, always-complete), (F) Gradle plugin kr.devfive.vespera-bridge with dogfooding, (D) streaming + JNI OutputStream proxy + bidirectional streaming. Multi-app: register_app_named + APP_ROUTERS> + wire app field (additive v=1) + jni_apps! macro. Multi-app is for external-dispatcher scenarios (JNI/WASM/FFI); Rust standalone uses axum native Router::merge/nest. Spring autoconfigure: VesperaBridgeAutoConfiguration + @ConditionalOnMissingBean + zero-config defaults (HeaderAppNameResolver + BidirectionalStreamingDispatchModeResolver). Single /** catch-all keeps OpenAPI URLs == Spring URLs. 4 DispatchMode values (SYNC / ASYNC / STREAMING / BIDIRECTIONAL_STREAMING). 6 JNI native symbols, 1846+ tests passing, clippy clean, 1 MiB SHA256 byte-identical bidirectional streaming roundtrip verified end-to-end. --- .gitignore | 3 + AGENTS.md | 105 +- Cargo.lock | 59 +- README.md | 43 +- crates/vespera/Cargo.toml | 4 + crates/vespera/src/lib.rs | 23 +- crates/vespera/tests/derive_garde_emit.rs | 140 + crates/vespera/tests/jni_validation.rs | 252 + crates/vespera/tests/multipart_wire.rs | 206 + crates/vespera_inprocess/Cargo.toml | 4 +- crates/vespera_inprocess/benches/dispatch.rs | 136 +- crates/vespera_inprocess/src/lib.rs | 1220 ++++- crates/vespera_inprocess/tests/binary_wire.rs | 448 ++ crates/vespera_inprocess/tests/error_wire.rs | 49 + crates/vespera_inprocess/tests/multi_app.rs | 213 + .../tests/register_app_idempotent.rs | 62 +- crates/vespera_inprocess/tests/wire_format.rs | 165 + crates/vespera_jni/src/lib.rs | 609 ++- crates/vespera_macro/src/garde_emit.rs | 120 +- .../src/parser/schema/schema_attrs.rs | 47 + crates/vespera_macro/src/route_impl.rs | 30 +- .../axum-example/tests/integration_test.rs | 11 +- examples/rust-jni-demo/README.md | 28 +- .../java/demo-app/build.gradle.kts | 71 +- .../src/main/resources/application.yml | 2 + .../rust-jni-demo/java/settings.gradle.kts | 21 + .../src/admin_routes/dashboard.rs | 31 + .../rust-jni-demo/src/admin_routes/mod.rs | 1 + examples/rust-jni-demo/src/lib.rs | 125 +- examples/rust-jni-demo/src/routes/echo.rs | 25 + examples/rust-jni-demo/src/routes/mod.rs | 1 + .../build.gradle.kts | 79 + .../gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + libs/vespera-bridge-gradle-plugin/gradlew | 252 + libs/vespera-bridge-gradle-plugin/gradlew.bat | 94 + .../settings.gradle.kts | 1 + .../devfive/vespera/VesperaBridgeExtension.kt | 48 + .../kr/devfive/vespera/VesperaBridgePlugin.kt | 160 + libs/vespera-bridge/README.md | 409 ++ libs/vespera-bridge/build.gradle.kts | 32 +- .../vespera/bridge/AppNameResolver.java | 36 + ...ectionalStreamingDispatchModeResolver.java | 28 + .../devfive/vespera/bridge/DispatchMode.java | 60 + .../vespera/bridge/DispatchModeResolver.java | 36 + .../vespera/bridge/HeaderAppNameResolver.java | 35 + .../devfive/vespera/bridge/VesperaBridge.java | 423 +- .../VesperaBridgeAutoConfiguration.java | 68 + .../bridge/VesperaBridgeProperties.java | 60 + .../bridge/VesperaProxyController.java | 286 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../vespera/bridge/VesperaWireTest.java | 238 + openapi.json | 4345 ----------------- 54 files changed, 6055 insertions(+), 4899 deletions(-) create mode 100644 crates/vespera/tests/jni_validation.rs create mode 100644 crates/vespera/tests/multipart_wire.rs create mode 100644 crates/vespera_inprocess/tests/binary_wire.rs create mode 100644 crates/vespera_inprocess/tests/error_wire.rs create mode 100644 crates/vespera_inprocess/tests/multi_app.rs create mode 100644 crates/vespera_inprocess/tests/wire_format.rs create mode 100644 examples/rust-jni-demo/src/admin_routes/dashboard.rs create mode 100644 examples/rust-jni-demo/src/admin_routes/mod.rs create mode 100644 examples/rust-jni-demo/src/routes/echo.rs create mode 100644 libs/vespera-bridge-gradle-plugin/build.gradle.kts create mode 100644 libs/vespera-bridge-gradle-plugin/gradle.properties create mode 100644 libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.jar create mode 100644 libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.properties create mode 100644 libs/vespera-bridge-gradle-plugin/gradlew create mode 100644 libs/vespera-bridge-gradle-plugin/gradlew.bat create mode 100644 libs/vespera-bridge-gradle-plugin/settings.gradle.kts create mode 100644 libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt create mode 100644 libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt create mode 100644 libs/vespera-bridge/README.md create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/AppNameResolver.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java create mode 100644 libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java create mode 100644 libs/vespera-bridge/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java delete mode 100644 openapi.json diff --git a/.gitignore b/.gitignore index 2ee8acb..f377187 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ bin/ .omc .omo node_modules + +# Generated OpenAPI artifacts at workspace root +/openapi*.json diff --git a/AGENTS.md b/AGENTS.md index 2c52219..1963271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ vespera/ │ ├── vespera_core/ # OpenAPI types, route/schema abstractions │ ├── vespera_macro/ # Proc-macros (main logic lives here) │ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic) -│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_json() +│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes() │ └── vespera_jni/ # JNI bridge (depends on vespera_inprocess) │ └── src/lib.rs # RUNTIME, jni_app! macro, JNI symbol export ├── libs/ @@ -46,7 +46,7 @@ vespera/ | Add core types | `crates/vespera_core/src/` | OpenAPI spec types | | Test new features | `examples/axum-example/` | Add route, run example | | In-process dispatch | `crates/vespera_inprocess/src/lib.rs` | RequestEnvelope → Router → ResponseEnvelope | -| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_json() | +| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_bytes() | | JNI integration | `crates/vespera_jni/src/lib.rs` | RUNTIME, jni_app! macro, JNI symbol export | | Java bridge library | `libs/vespera-bridge/` | com.devfive.vespera.bridge package | | JNI demo (Rust) | `examples/rust-jni-demo/src/` | Routes + vespera::jni_app! | @@ -76,9 +76,10 @@ vespera (OpenAPI framework) vespera_inprocess (transport layer — no JNI deps) ├── axum (direct — owns Router re-export) + ├── bytes (Bytes for zero-copy body handling) ├── http, http-body-util, tower ├── serde, serde_json - └── tokio (rt only — for dispatch_from_json Runtime param) + └── tokio (rt only — for dispatch_from_bytes Runtime param) vespera_jni (JNI glue — thin layer) ├── vespera_inprocess (via workspace) @@ -120,17 +121,97 @@ Feature flags: ## JNI ARCHITECTURE ``` -Java (Spring Boot) Rust (cdylib) vespera crates -───────────────── ────────────── ───────────────── -VesperaBridge.init() → JNI_OnLoad vespera_inprocess::register_app() - ↓ ↓ -VesperaBridge.dispatch() → JNI symbol vespera_inprocess::dispatch_from_json() - ↓ ↓ ↓ -VesperaProxyController catch_unwind router.oneshot(request) - ↓ ↓ ↓ -ResponseEntity JSON envelope axum handlers +Java (Spring Boot) Rust (cdylib) vespera crates +───────────────── ────────────── ───────────────── +VesperaBridge.init() → JNI_OnLoad vespera_inprocess::register_app() + ↓ ↓ +VesperaBridge.dispatchBytes() → JNI symbol vespera_inprocess::dispatch_from_bytes() + ↓ ↓ ↓ +VesperaProxyController catch_unwind router.oneshot(request) + ↓ ↓ ↓ +ResponseEntity binary wire response axum handlers + (String OR byte[]) [u32 BE | JSON | body] ``` +### Binary Wire Format + +Both request and response use the same layout: + +``` +bytes 0..4 : u32 BE = header_json byte length N +bytes 4..4+N : UTF-8 JSON + (request) { "v":1, "method", "path", + "query"?, "headers"? } + (response) { "v":1, "status", "headers", + "metadata", "validation_errors"? } +bytes 4+N.. : raw body bytes (UTF-8 text or binary — + no encoding applied) +``` + +- No base64 — multipart uploads / PDFs / images travel as raw bytes. +- `"v":1` is the protocol version; mismatched versions get a `400` wire response. +- All failure modes (malformed wire, panic in Rust, no app registered) return a valid length-prefixed wire response, so the Java decoder never has to special-case errors. +- `validation_errors` is an optional array hoisted from 422 JSON bodies (`{"errors":[...]}`) — original body preserved verbatim alongside. + +### JNI Dispatch Modes (four symbols) + +| Symbol | Java native | Mode | Memory | +|---|---|---|---| +| `Java_...dispatchBytes` | `byte[] dispatchBytes(byte[])` | sync | full body | +| `Java_...dispatchAsync` | `void dispatchAsync(CompletableFuture, byte[])` | async | full body | +| `Java_...dispatchStreaming` | `byte[] dispatchStreaming(byte[], OutputStream)` | sync response-streaming | chunk-bounded response | +| `Java_...dispatchFullStreaming` | `byte[] dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync bidirectional streaming | chunk-bounded both directions | + +All four share the same wire format, registered router, and panic-safe `catch_unwind` discipline. `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a worker thread via `attach_current_thread`. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls 16 KiB chunks from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded 16-slot channel) so 1 GiB uploads run in `O(chunk_size)` RAM. + +### Rust Public API (vespera_inprocess) + +| Function | Sig | Use | +|---|---|---| +| `register_app(F)` | sync | Register the default app (first-wins, BC) | +| `register_app_named(&str, F)` | sync | Register a named app for multi-app routing | +| `dispatch_from_bytes(Vec, &Runtime) -> Vec` | sync | FFI entry, blocks on runtime | +| `dispatch_from_bytes_async(Vec) -> Vec` (async) | async | inside an existing runtime | +| `dispatch_streaming_async(Vec, F) -> Vec` (async) | response streaming async | `F: FnMut(&[u8])` body chunks | +| `dispatch_bidirectional_streaming(Vec, P, F) -> Vec` (async) | bidirectional streaming | `P: FnMut() -> Option> + Send + 'static`, `F: FnMut(&[u8])` | +| `error_wire(u16, &str) -> Vec` | sync | wire-format error builder | +| `dispatch_typed(Router, &RequestEnvelope) -> ResponseEnvelope` | async | direct axum API (BC) | + +### Multi-app routing + +**Use case**: multi-app is primarily a feature for **external-dispatcher scenarios** — JNI (Java host picks app per request via header), WebAssembly bridge, C FFI, or any in-process embedding where the host distinguishes between multiple independent vespera API surfaces. For Rust **standalone** servers (`axum::serve(...)`), the native axum patterns (`Router::merge()`, `Router::nest()`) are more idiomatic for modularization — `register_app_named` adds no value when the same binary owns both the router registration and the HTTP entry point. + +The wire header carries an optional `"app": ""` field (default +omitted → `"_default"` app). Dispatch looks the name up in +`APP_ROUTERS: RwLock>` and returns: + +- 404 wire response if the name is registered but no such app exists +- 400 wire response if the name fails validation (non-empty, ≤ 64 bytes, `[A-Za-z0-9_-]`) +- Otherwise the matching `Router` is cloned (Arc-backed) and dispatched + +Two Rust-side macros assemble the single mandatory `JNI_OnLoad`: + +```rust +vespera::jni_app!(create_app); // BC sugar for single default app + +vespera::jni_apps! { // multi-app primary API + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +### Spring Boot autoconfigure (Java side) + +`vespera-bridge` ships a Spring Boot autoconfiguration that wires up +`VesperaProxyController` + two strategy beans, both replaceable via +`@ConditionalOnMissingBean`: + +- `AppNameResolver` (default: `HeaderAppNameResolver("X-Vespera-App")`) — picks app per request +- `DispatchModeResolver` (default: `BidirectionalStreamingDispatchModeResolver`) — picks `DispatchMode` + +Property `vespera.bridge.controller-enabled=false` disables the whole controller for BYO scenarios. See [`libs/vespera-bridge/README.md`](libs/vespera-bridge/README.md#customization) for the customization recipes. + ### Rust side (example app — 2 lines of JNI code): ```rust pub fn create_app() -> axum::Router { vespera!(...) } diff --git a/Cargo.lock b/Cargo.lock index 5cdabd2..acb3aaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,15 +42,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -802,24 +793,25 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "criterion" -version = "0.8.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ - "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "itertools 0.13.0", + "is-terminal", + "itertools 0.10.5", "num-traits", + "once_cell", "oorandom", - "page_size", "plotters", "rayon", "regex", "serde", + "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -827,12 +819,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.8.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools 0.13.0", + "itertools 0.10.5", ] [[package]] @@ -1452,6 +1444,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1755,11 +1753,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "itertools" -version = "0.13.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -2213,16 +2222,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "parking" version = "2.2.1" @@ -4017,8 +4016,10 @@ name = "vespera_inprocess" version = "0.1.51" dependencies = [ "axum", + "bytes", "criterion", "http", + "http-body", "http-body-util", "serde", "serde_json", diff --git a/README.md b/README.md index b332d37..63646f8 100644 --- a/README.md +++ b/README.md @@ -714,16 +714,51 @@ This automatically: --- +## JNI / Java Integration + +Embed your Vespera router inside a Java/Spring application — no TCP, no JSON envelope overhead. + +```rust +// Cargo.toml +// vespera = { version = "0.1", features = ["jni"] } + +pub fn create_app() -> axum::Router { + vespera!(title = "My API") +} + +vespera::jni_app!(create_app); +``` + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); + SpringApplication.run(MyApp.class, args); + } +} +``` + +The `VesperaProxyController` auto-registers as a catch-all and forwards every HTTP request through a length-prefixed **binary wire format** (`[u32 BE | UTF-8 JSON header | raw body]`) — multipart uploads, PDFs, and images travel raw, with zero base64 overhead. + +See [`libs/vespera-bridge`](./libs/vespera-bridge/) for the Java library docs and [`examples/rust-jni-demo`](./examples/rust-jni-demo/) for a complete end-to-end demo. + ## Project Structure ``` vespera/ ├── crates/ -│ ├── vespera/ # Main crate - re-exports everything -│ ├── vespera_core/ # OpenAPI types and abstractions -│ └── vespera_macro/ # Proc-macros (compile-time magic) +│ ├── vespera/ # Main crate - re-exports everything +│ ├── vespera_core/ # OpenAPI types and abstractions +│ ├── vespera_macro/ # Proc-macros (compile-time magic) +│ ├── vespera_inprocess/ # In-process axum dispatch + binary wire API +│ └── vespera_jni/ # JNI glue (Runtime + JNI symbol) +├── libs/ +│ └── vespera-bridge/ # Java library (kr.devfive:vespera-bridge) └── examples/ - └── axum-example/ # Complete example application + ├── axum-example/ # Standalone OpenAPI server + └── rust-jni-demo/ # Rust + Spring Boot JNI integration ``` --- diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 2ac6a0d..406343c 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -53,6 +53,10 @@ garde = { workspace = true, optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tower = { version = "0.5", features = ["util"] } +# Integration tests for the JNI dispatch path call +# `vespera_inprocess::{register_app, dispatch_from_json}` directly so +# they don't need the `inprocess` cargo feature to be enabled. +vespera_inprocess = { workspace = true } [lints] workspace = true diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index 6c3e22b..ff5d3a7 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -147,7 +147,8 @@ pub use vespera_inprocess as inprocess; #[cfg(feature = "jni")] pub use vespera_jni as jni; -/// Generate the `JNI_OnLoad` export that registers your app. +/// Generate the `JNI_OnLoad` export that registers your app +/// (single-app, default). /// /// ```ignore /// vespera::jni_app!(create_app); @@ -159,3 +160,23 @@ macro_rules! jni_app { $crate::jni::jni_app!($factory); }; } + +/// Generate the `JNI_OnLoad` export that registers **multiple named +/// apps** for multi-app routing. See [`vespera_jni::jni_apps!`] for +/// details. +/// +/// ```ignore +/// vespera::jni_apps! { +/// "admin" => admin_app, +/// "public" => public_app, +/// } +/// ``` +#[cfg(feature = "jni")] +#[macro_export] +macro_rules! jni_apps { + ( $( $name:literal => $factory:expr ),+ $(,)? ) => { + $crate::jni::jni_apps! { + $( $name => $factory ),+ + } + }; +} diff --git a/crates/vespera/tests/derive_garde_emit.rs b/crates/vespera/tests/derive_garde_emit.rs index bd11d8a..d1b091a 100644 --- a/crates/vespera/tests/derive_garde_emit.rs +++ b/crates/vespera/tests/derive_garde_emit.rs @@ -142,3 +142,143 @@ fn multiple_field_violations_all_reported_in_one_report() { // username triggers both pattern and (implicitly satisfied) length. assert!(report.iter().count() >= 3, "got {paths:?}"); } + +// ── nested validation via `#[schema(dive)]` ────────────────────────── + +#[derive(Schema, serde::Deserialize)] +#[allow(dead_code)] +struct Address { + #[schema(min_length = 1, max_length = 64)] + pub city: String, + #[schema(pattern = "^[A-Z0-9-]+$")] + pub postal_code: String, +} + +#[derive(Schema, serde::Deserialize)] +#[allow(dead_code)] +struct LineItem { + #[schema(min_length = 1)] + pub sku: String, + #[schema(minimum = 1)] + pub quantity: u32, +} + +#[derive(Schema, serde::Deserialize)] +#[allow(dead_code, clippy::struct_field_names)] +struct Order { + #[schema(min_length = 1)] + pub order_id: String, + + #[schema(dive)] + pub primary_address: Address, + + #[schema(dive)] + pub billing_address: Option
, + + #[schema(min_items = 1, max_items = 100, dive)] + pub line_items: Vec, +} + +fn good_order() -> Order { + Order { + order_id: "ORD-001".to_owned(), + primary_address: Address { + city: "Seoul".to_owned(), + postal_code: "12345".to_owned(), + }, + billing_address: None, + line_items: vec![LineItem { + sku: "SKU-1".to_owned(), + quantity: 2, + }], + } +} + +#[test] +fn nested_validation_clean_order_passes() { + assert!(good_order().validate().is_ok()); +} + +#[test] +fn nested_validation_inner_field_violation_reports_dotted_path() { + let mut o = good_order(); + o.primary_address.city = String::new(); // violates min_length = 1 + let report = o.validate().expect_err("nested validation must fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!( + paths.iter().any(|p| p == "primary_address.city"), + "expected dotted path `primary_address.city`, got {paths:?}" + ); +} + +#[test] +fn nested_validation_option_none_skips_inner_checks() { + // billing_address = None → inner validation must not run, no + // billing_address.* errors in the report. + let o = good_order(); + assert!(o.billing_address.is_none()); + assert!(o.validate().is_ok()); +} + +#[test] +fn nested_validation_option_some_runs_inner_checks() { + let mut o = good_order(); + o.billing_address = Some(Address { + city: String::new(), // violates min_length = 1 + postal_code: "ZZ999".to_owned(), // valid pattern + }); + let report = o.validate().expect_err("billing_address Some must validate"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!( + paths.iter().any(|p| p == "billing_address.city"), + "expected `billing_address.city`, got {paths:?}" + ); +} + +#[test] +fn nested_validation_vec_iterates_with_indexed_path() { + let mut o = good_order(); + o.line_items = vec![ + LineItem { + sku: "OK-1".to_owned(), + quantity: 1, + }, + LineItem { + sku: String::new(), // violates min_length=1 at index 1 + quantity: 0, // violates minimum=1 at index 1 + }, + ]; + let report = o.validate().expect_err("line_items[1] should fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!( + paths.iter().any(|p| p == "line_items[1].sku"), + "expected indexed path `line_items[1].sku`, got {paths:?}" + ); + assert!( + paths.iter().any(|p| p == "line_items[1].quantity"), + "expected indexed path `line_items[1].quantity`, got {paths:?}" + ); +} + +#[test] +fn nested_validation_vec_min_items_and_dive_both_enforced() { + let mut o = good_order(); + o.line_items.clear(); // violates min_items = 1 + let report = o.validate().expect_err("empty line_items must fail"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!( + paths.iter().any(|p| p == "line_items"), + "expected outer `line_items` length error, got {paths:?}" + ); +} + +#[test] +fn nested_validation_outer_and_inner_violations_both_reported() { + let mut o = good_order(); + o.order_id = String::new(); // outer min_length=1 + o.primary_address.postal_code = "lowercase".to_owned(); // inner pattern + let report = o.validate().expect_err("two-level failure"); + let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); + assert!(paths.iter().any(|p| p == "order_id")); + assert!(paths.iter().any(|p| p == "primary_address.postal_code")); +} diff --git a/crates/vespera/tests/jni_validation.rs b/crates/vespera/tests/jni_validation.rs new file mode 100644 index 0000000..49c37e3 --- /dev/null +++ b/crates/vespera/tests/jni_validation.rs @@ -0,0 +1,252 @@ +//! End-to-end integration test for the JNI dispatch path +//! ([`vespera_inprocess::dispatch_from_bytes`]) + the `Validated` +//! extractor. +//! +//! Pins the contract that **422 validation failures crossing the JNI +//! boundary surface as a wire-format response with `status: 422`, +//! `content-type: application/json`, and a body containing the +//! structured error array** — the Java side must be able to read +//! both the status and the error array out of the wire response it +//! receives back from the cdylib. + +#![cfg(feature = "validation")] + +use ::axum::{Router, routing::post}; +use ::serde::Deserialize; +use ::serde_json::{Value, json}; +use ::std::collections::HashMap; +use ::std::sync::Once; +use ::tokio::runtime::Builder; +use ::vespera::{Schema, Validated, axum::Json}; +use ::vespera_inprocess::{dispatch_from_bytes, register_app}; + +#[derive(Deserialize, Schema)] +#[allow(dead_code)] +struct JniReq { + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z0-9_]+$")] + username: String, + + #[schema(format = "email")] + email: String, + + #[schema(minimum = 18, maximum = 150)] + age: u32, +} + +async fn jni_handler( + Validated(Json(_payload)): Validated>, +) -> &'static str { + "ok" +} + +fn jni_router() -> Router { + Router::new().route("/v/users", post(jni_handler)) +} + +fn install_router_once() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| register_app(jni_router)); +} + +/// Encode a POST wire request with body bytes + headers. +fn encode_wire_post(path: &str, headers: HashMap<&str, &str>, body: &[u8]) -> Vec { + let headers_json: serde_json::Map = headers + .into_iter() + .map(|(k, v)| (k.to_owned(), Value::String(v.to_owned()))) + .collect(); + let header = json!({ + "v": 1, + "method": "POST", + "path": path, + "headers": headers_json, + }); + let header_bytes = serde_json::to_vec(&header).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +/// Decode a wire response: `(header_json, body_bytes)`. +fn decode_wire(resp: &[u8]) -> (Value, Vec) { + assert!(resp.len() >= 4, "wire response too short"); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!(4 + header_len <= resp.len(), "header_len overflows response"); + let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) + .expect("response header is valid JSON"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +/// Dispatch a JSON body and return `(header, body_as_value)`. Body is +/// parsed as JSON because validation failures emit JSON. +fn dispatch_json_body(body: &Value) -> (Value, Value) { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let body_bytes = body.to_string().into_bytes(); + let wire = encode_wire_post( + "/v/users", + HashMap::from([("content-type", "application/json")]), + &body_bytes, + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body_bytes) = decode_wire(&resp); + let body_value: Value = if body_bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body_bytes).unwrap_or(Value::Null) + }; + (header, body_value) +} + +fn good_body() -> Value { + json!({ + "username": "alice_99", + "email": "alice@example.com", + "age": 30 + }) +} + +#[test] +fn jni_dispatch_valid_payload_returns_200_envelope() { + let (header, body) = dispatch_json_body(&good_body()); + assert_eq!( + header["status"].as_u64().expect("status is integer"), + 200, + "valid payload must produce 200: header={header:#} body={body:#}" + ); + // 200 OK body is the literal string "ok" (axum's IntoResponse for &'static str). + // It's not JSON, so body_value falls through to Null; verify via raw bytes too. + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let wire = encode_wire_post( + "/v/users", + HashMap::from([("content-type", "application/json")]), + good_body().to_string().as_bytes(), + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (_h, body_bytes) = decode_wire(&resp); + assert_eq!( + String::from_utf8_lossy(&body_bytes), + "ok", + "valid payload body must be \"ok\"" + ); +} + +#[test] +fn jni_dispatch_short_username_returns_422_envelope_with_path() { + let body = json!({ + "username": "x", + "email": "alice@example.com", + "age": 30 + }); + let (header, error_body) = dispatch_json_body(&body); + + assert_eq!( + header["status"].as_u64().expect("status is integer"), + 422, + "validation failure must surface in wire.status: header={header:#}" + ); + + let content_type = header["headers"]["content-type"] + .as_str() + .expect("content-type header missing"); + assert_eq!(content_type, "application/json"); + + // (a) Original body preserved (BC contract): errors array still in body. + let errors = error_body["errors"] + .as_array() + .unwrap_or_else(|| panic!("errors array missing in body: {error_body:#}")); + assert!( + errors.iter().any(|e| e["path"].as_str() == Some("username")), + "expected `username` in error paths, got {error_body:#}" + ); + + // (b) NEW: validation_errors hoisted into wire header — single parse for Java. + let hoisted = header["validation_errors"] + .as_array() + .unwrap_or_else(|| panic!("validation_errors missing in wire header: {header:#}")); + assert!( + hoisted + .iter() + .any(|e| e["path"].as_str() == Some("username")), + "expected `username` in hoisted validation_errors, got {header:#}" + ); +} + +#[test] +fn jni_dispatch_invalid_email_returns_422_envelope() { + let body = json!({ + "username": "valid_user", + "email": "not-an-email", + "age": 30 + }); + let (header, error_body) = dispatch_json_body(&body); + + assert_eq!(header["status"].as_u64().unwrap(), 422); + let errors = error_body["errors"].as_array().unwrap(); + assert!(errors.iter().any(|e| e["path"].as_str() == Some("email"))); +} + +#[test] +fn jni_dispatch_out_of_range_age_returns_422_envelope() { + let body = json!({ + "username": "valid_user", + "email": "alice@example.com", + "age": 200_u32 + }); + let (header, error_body) = dispatch_json_body(&body); + + assert_eq!(header["status"].as_u64().unwrap(), 422); + let errors = error_body["errors"].as_array().unwrap(); + assert!(errors.iter().any(|e| e["path"].as_str() == Some("age"))); +} + +#[test] +fn jni_dispatch_multiple_violations_envelope_contains_all_paths() { + let body = json!({ + "username": "X", // pattern + min_length + "email": "broken", // format=email + "age": 9999_u32 // range max + }); + let (header, error_body) = dispatch_json_body(&body); + + assert_eq!(header["status"].as_u64().unwrap(), 422); + let errors = error_body["errors"].as_array().unwrap(); + let paths: Vec<&str> = errors + .iter() + .filter_map(|e| e["path"].as_str()) + .collect(); + assert!(paths.contains(&"username"), "got {paths:?}"); + assert!(paths.contains(&"email"), "got {paths:?}"); + assert!(paths.contains(&"age"), "got {paths:?}"); + + // NEW: hoisted validation_errors must mirror the body. + let hoisted = header["validation_errors"].as_array().unwrap(); + let hoisted_paths: Vec<&str> = hoisted + .iter() + .filter_map(|e| e["path"].as_str()) + .collect(); + assert!(hoisted_paths.contains(&"username"), "got {hoisted_paths:?}"); + assert!(hoisted_paths.contains(&"email"), "got {hoisted_paths:?}"); + assert!(hoisted_paths.contains(&"age"), "got {hoisted_paths:?}"); +} + +#[test] +fn jni_dispatch_200_response_does_not_carry_validation_errors() { + let (header, _body) = dispatch_json_body(&good_body()); + assert_eq!(header["status"].as_u64().unwrap(), 200); + assert!( + header["validation_errors"].is_null(), + "200 response must not carry validation_errors field, got {header:#}" + ); +} diff --git a/crates/vespera/tests/multipart_wire.rs b/crates/vespera/tests/multipart_wire.rs new file mode 100644 index 0000000..7cc4ef5 --- /dev/null +++ b/crates/vespera/tests/multipart_wire.rs @@ -0,0 +1,206 @@ +//! Integration test for [`vespera::multipart::TypedMultipart`] over the +//! binary wire ([`vespera_inprocess::dispatch_from_bytes`]). +//! +//! Pins the contract that real-world `multipart/form-data` file uploads +//! pass through the binary wire envelope **byte-for-byte intact** — +//! `boundary`-delimited parts arrive at the `TypedMultipart` +//! extractor exactly as constructed. +//! +//! Includes a 256 KiB payload to exercise the +//! `axum::extract::DefaultBodyLimit::disable()` layer; without it +//! axum's default 2 MiB cap would silently truncate larger uploads. + +use ::axum::{Router, extract::DefaultBodyLimit, routing::post}; +use ::serde::Serialize; +use ::serde_json::Value; +use ::std::collections::HashMap; +use ::std::io::{Read, Seek, SeekFrom}; +use ::std::sync::Once; +use ::tokio::runtime::Builder; +use ::vespera::axum::Json; +use ::vespera::multipart::{FieldData, TypedMultipart}; +use ::vespera::tempfile::NamedTempFile; +use ::vespera::{Multipart, Schema}; +use ::vespera_inprocess::{dispatch_from_bytes, register_app}; + +#[derive(Multipart, Schema)] +#[allow(dead_code)] +struct UploadReq { + name: String, + file: FieldData, +} + +#[derive(Serialize, Schema)] +struct UploadResult { + name: String, + file_size: u64, + file_first_byte: u8, + file_last_byte: u8, +} + +async fn upload_handler( + TypedMultipart(mut req): TypedMultipart, +) -> Json { + let mut buf = Vec::new(); + let f = req.file.contents.as_file_mut(); + // multipart parser leaves the file cursor at EOF after writing + f.seek(SeekFrom::Start(0)).expect("rewind temp file"); + f.read_to_end(&mut buf).expect("read temp file"); + let len = u64::try_from(buf.len()).expect("file size fits in u64"); + let first = *buf.first().unwrap_or(&0); + let last = *buf.last().unwrap_or(&0); + Json(UploadResult { + name: req.name, + file_size: len, + file_first_byte: first, + file_last_byte: last, + }) +} + +fn multipart_router() -> Router { + Router::new() + .route("/upload", post(upload_handler)) + // Disable the 2 MiB default so the 256 KiB test below isn't + // truncated — and so end-users can document a sensible policy + // explicitly rather than inheriting an axum default that's + // surprising in an in-process / JNI context. + .layer(DefaultBodyLimit::disable()) +} + +fn install_router_once() { + static INIT: Once = Once::new(); + INIT.call_once(|| register_app(multipart_router)); +} + +fn encode_multipart_wire( + boundary: &str, + name: &str, + file_name: &str, + file_bytes: &[u8], +) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"name\"\r\n\r\n"); + body.extend_from_slice(name.as_bytes()); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n") + .as_bytes(), + ); + body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); + body.extend_from_slice(file_bytes); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + + let mut headers = HashMap::new(); + headers.insert( + "content-type".to_owned(), + format!("multipart/form-data; boundary={boundary}"), + ); + let header_json = ::serde_json::json!({ + "v": 1, + "method": "POST", + "path": "/upload", + "headers": headers, + }); + let header_bytes = ::serde_json::to_vec(&header_json).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(&body); + wire +} + +fn decode_wire(resp: &[u8]) -> (Value, Vec) { + assert!(resp.len() >= 4, "wire response too short"); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!(4 + header_len <= resp.len(), "header_len overflow"); + let header: Value = + ::serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +#[test] +fn typed_multipart_small_binary_roundtrip() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let payload: Vec = (0u32..1024) + .map(|i| u8::try_from(i % 256).expect("mod 256")) + .collect(); + let wire = encode_multipart_wire("----TestBoundary12345", "alice", "data.bin", &payload); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + + assert_eq!( + header["status"].as_u64(), + Some(200), + "expected 200, got header={header:#}" + ); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["name"], "alice"); + assert_eq!(json["file_size"], 1024); + assert_eq!(json["file_first_byte"], 0); + assert_eq!(json["file_last_byte"], 255); +} + +#[test] +fn typed_multipart_large_binary_roundtrip() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + // 256 KiB - exercises FieldData tempfile path AND + // proves DefaultBodyLimit::disable() works; without it the default + // 2 MiB cap would only catch this above 2 MiB, but the existence + // of this layer is the contract under test. + let size: u32 = 256 * 1024; + let payload: Vec = (0..size) + .map(|i| u8::try_from(i % 256).expect("mod 256")) + .collect(); + let wire = encode_multipart_wire("----LargeBoundary", "report", "big.bin", &payload); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + + assert_eq!( + header["status"].as_u64(), + Some(200), + "expected 200, got header={header:#}" + ); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["file_size"].as_u64(), Some(u64::from(size))); +} + +#[test] +fn typed_multipart_non_utf8_bytes_preserved() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + // Mixed invalid UTF-8 + binary signatures — proves the wire passes + // bytes verbatim with NO UTF-8 coercion anywhere in the pipeline. + let payload: Vec = vec![0x00, 0xFF, 0xC0, 0xC0, 0xDE, 0xAD, 0xBE, 0xEF]; + let wire = encode_multipart_wire("----NonUtf8Boundary", "bin", "raw.bin", &payload); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + + assert_eq!( + header["status"].as_u64(), + Some(200), + "expected 200, got header={header:#}" + ); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["file_size"], 8); + assert_eq!(json["file_first_byte"], 0); + assert_eq!(json["file_last_byte"], 0xEF); +} diff --git a/crates/vespera_inprocess/Cargo.toml b/crates/vespera_inprocess/Cargo.toml index 756b00b..e366801 100644 --- a/crates/vespera_inprocess/Cargo.toml +++ b/crates/vespera_inprocess/Cargo.toml @@ -8,7 +8,9 @@ repository.workspace = true [dependencies] axum = "0.8" +bytes = "1" http = "1" +http-body = "1" http-body-util = "0.1" tower = { version = "0.5", features = ["util"] } serde = { version = "1", features = ["derive"] } @@ -16,7 +18,7 @@ serde_json = "1" tokio = { version = "1", features = ["rt"] } [dev-dependencies] -criterion = { version = "0.8", features = ["html_reports"] } +criterion = { version = "0.5", features = ["html_reports"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [[bench]] diff --git a/crates/vespera_inprocess/benches/dispatch.rs b/crates/vespera_inprocess/benches/dispatch.rs index 3d93e35..ee08cc1 100644 --- a/crates/vespera_inprocess/benches/dispatch.rs +++ b/crates/vespera_inprocess/benches/dispatch.rs @@ -1,16 +1,13 @@ -//! Criterion benchmarks quantifying the performance review patches. +//! Criterion benchmarks for the in-process dispatch surface. //! -//! Each benchmark group compares **two paths** that are both reachable -//! from the *current* code base, so a single `cargo bench` run produces -//! the before/after comparison without git tricks: +//! Three groups: //! //! - `router_path`: `Router::clone()` of a pre-built router (post-P1) //! vs rebuilding the router from a factory closure (pre-P1, simulated). //! - `dispatch_path`: `dispatch_owned(router, env)` (post-P2) -//! vs `dispatch(router, &env)` which clones internally (pre-P2). -//! - `full_flow`: realistic JNI flow `dispatch_from_json`-style — parse + -//! cached router + owned dispatch (post-P1+P2) vs parse + per-call -//! build + borrowed dispatch (pre-P1+P2). +//! vs `dispatch_typed(router, &env)` which clones internally (pre-P2). +//! - `wire_path`: end-to-end `dispatch_from_bytes` — wire-format +//! round-trip including header JSON parse + body byte handling. //! //! Scaling axes: //! - `route_count`: 10 / 100 / 500 routes (Router-build dominance). @@ -25,7 +22,9 @@ use axum::{ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; -use vespera_inprocess::{RequestEnvelope, dispatch, dispatch_owned, dispatch_typed, parse_request}; +use vespera_inprocess::{ + RequestEnvelope, dispatch_from_bytes, dispatch_owned, dispatch_typed, register_app, +}; // ── Test fixtures ──────────────────────────────────────────────────── @@ -43,9 +42,7 @@ async fn handler_echo(Json(payload): Json) -> Json { } /// Build a router with `n_routes` distinct GET endpoints plus one -/// `POST /echo` that echoes the request body. This simulates the -/// `vespera!()` macro-expanded `Router::new().route(...).route(...)...` -/// chain that runs inside the user's `create_app()`. +/// `POST /echo` that echoes the request body. fn build_router(n_routes: usize) -> Router { let mut router = Router::new().route("/echo", post(handler_echo)); for i in 0..n_routes { @@ -55,23 +52,7 @@ fn build_router(n_routes: usize) -> Router { router } -/// JSON-encoded `RequestEnvelope` whose body is `body_kb * 1024` bytes -/// of valid UTF-8 (so we measure the realistic clone/move cost without -/// triggering the lossy decode path). -fn make_envelope_json(body_kb: usize) -> String { - let body_str = "x".repeat(body_kb * 1024); - let envelope = serde_json::json!({ - "method": "POST", - "path": "/echo", - "query": "", - "headers": { "content-type": "application/json" }, - "body": serde_json::to_string(&Echo { body: body_str }).unwrap(), - }); - envelope.to_string() -} - -/// Owned `RequestEnvelope` mirror of `make_envelope_json` for the -/// dispatch-only benches that skip the JSON parse step. +/// Owned `RequestEnvelope` for the direct-API benches. fn make_envelope(body_kb: usize) -> RequestEnvelope { let body_str = "x".repeat(body_kb * 1024); let mut headers = HashMap::new(); @@ -85,35 +66,31 @@ fn make_envelope(body_kb: usize) -> RequestEnvelope { } } -// ── Naive (pre-patch) reference paths ──────────────────────────────── - -/// Simulates the pre-patch `dispatch_from_json`: -/// factory() per call + dispatch with borrowed envelope (internal clone). -fn naive_dispatch_from_json( - input: &str, - runtime: &Runtime, - factory: &dyn Fn() -> Router, -) -> String { - let envelope = parse_request(input).expect("valid envelope"); - let router = factory(); // pre-P1: factory called per request - runtime.block_on(dispatch(router, &envelope)) // pre-P2: dispatch clones envelope internally -} - -/// Simulates the post-patch hot path explicitly so the comparison -/// against `naive_dispatch_from_json` is apples-to-apples (no detour -/// through the global `APP_ROUTER` `OnceLock`). -fn patched_dispatch_from_json(input: &str, runtime: &Runtime, cached_router: &Router) -> String { - let envelope = parse_request(input).expect("valid envelope"); - let router = cached_router.clone(); // post-P1: cheap Arc-backed clone - let response = runtime.block_on(dispatch_owned(router, envelope)); - serde_json::to_string(&response).expect("response is serializable") +/// Wire-format request payload for the `dispatch_from_bytes` bench. +fn make_wire_request(body_kb: usize) -> Vec { + let body_str = serde_json::to_string(&Echo { + body: "x".repeat(body_kb * 1024), + }) + .unwrap(); + let header = serde_json::json!({ + "v": 1, + "method": "POST", + "path": "/echo", + "headers": {"content-type": "application/json"}, + }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let body_bytes = body_str.as_bytes(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body_bytes); + wire } // ── Benchmarks ─────────────────────────────────────────────────────── -/// P1 isolation: cached Router::clone() vs factory rebuild per call. -/// Dispatch step is identical (`dispatch_owned`) on both sides so any -/// delta is attributable to router construction. +/// P1 isolation: cached `Router::clone()` vs factory rebuild per call. fn bench_router_path(c: &mut Criterion) { let runtime = Runtime::new().expect("tokio runtime"); let envelope_template = make_envelope(1); // 1 KB body, fixed @@ -148,20 +125,15 @@ fn bench_router_path(c: &mut Criterion) { group.finish(); } -/// P2 isolation: `dispatch_owned` (envelope moved into HTTP request) vs -/// `dispatch_typed` (envelope borrowed → clone then `dispatch_owned` -/// internally). Each iteration **freshly parses** the envelope from JSON -/// so the owned path genuinely avoids a clone; the borrowed path pays -/// for exactly one extra `RequestEnvelope::clone()` inside -/// `dispatch_typed`. Both arms return `ResponseEnvelope` so the -/// response-JSON serialization cost is excluded. +/// P2 isolation: `dispatch_owned` (envelope moved) vs `dispatch_typed` +/// (envelope borrowed → cloned internally). fn bench_dispatch_path(c: &mut Criterion) { let runtime = Runtime::new().expect("tokio runtime"); let cached = build_router(20); let mut group = c.benchmark_group("dispatch_path"); for &body_kb in &[1_usize, 64, 1024] { - let envelope_json = make_envelope_json(body_kb); + let template = make_envelope(body_kb); group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); group.bench_with_input( @@ -169,8 +141,7 @@ fn bench_dispatch_path(c: &mut Criterion) { &body_kb, |b, _| { b.iter(|| { - let env = parse_request(&envelope_json).expect("valid envelope"); - runtime.block_on(dispatch_owned(cached.clone(), env)) + runtime.block_on(dispatch_owned(cached.clone(), template.clone())) }); }, ); @@ -179,10 +150,7 @@ fn bench_dispatch_path(c: &mut Criterion) { BenchmarkId::new("borrowed_pre_P2", body_kb), &body_kb, |b, _| { - b.iter(|| { - let env = parse_request(&envelope_json).expect("valid envelope"); - runtime.block_on(dispatch_typed(cached.clone(), &env)) - }); + b.iter(|| runtime.block_on(dispatch_typed(cached.clone(), &template))); }, ); } @@ -190,38 +158,32 @@ fn bench_dispatch_path(c: &mut Criterion) { group.finish(); } -/// End-to-end JNI-style flow: JSON in → JSON out. Combines P1 + P2 so -/// the headline “Router rebuild + body clone” cost is visible. -fn bench_full_flow(c: &mut Criterion) { +/// End-to-end binary-wire flow: encoded request bytes → decoded +/// response bytes via the registered app. Measures the realistic FFI +/// cost the JNI bridge pays. +fn bench_wire_path(c: &mut Criterion) { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| register_app(|| build_router(100))); + let runtime = Runtime::new().expect("tokio runtime"); - let cached_100 = build_router(100); - let mut group = c.benchmark_group("full_flow"); + let mut group = c.benchmark_group("wire_path"); for &body_kb in &[1_usize, 64, 1024] { - let envelope_json = make_envelope_json(body_kb); + let wire = make_wire_request(body_kb); group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); group.bench_with_input( - BenchmarkId::new("patched_post_P1_P2", body_kb), + BenchmarkId::new("dispatch_from_bytes", body_kb), &body_kb, |b, _| { - b.iter(|| patched_dispatch_from_json(&envelope_json, &runtime, &cached_100)); - }, - ); - - group.bench_with_input( - BenchmarkId::new("naive_pre_P1_P2", body_kb), - &body_kb, - |b, _| { - b.iter(|| { - naive_dispatch_from_json(&envelope_json, &runtime, &|| build_router(100)) - }); + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); }, ); } group.finish(); + drop(runtime); } -criterion_group!(benches, bench_router_path, bench_dispatch_path, bench_full_flow); +criterion_group!(benches, bench_router_path, bench_dispatch_path, bench_wire_path); criterion_main!(benches); diff --git a/crates/vespera_inprocess/src/lib.rs b/crates/vespera_inprocess/src/lib.rs index 66290ea..f35f74c 100644 --- a/crates/vespera_inprocess/src/lib.rs +++ b/crates/vespera_inprocess/src/lib.rs @@ -2,46 +2,71 @@ //! [`Router`] without a TCP socket. //! //! This crate is **transport-agnostic** — it knows nothing about JNI, -//! C FFI, or WASM. It provides: +//! C FFI, or WASM. It exposes two API layers on top of a single +//! shared dispatch core: //! -//! 1. [`dispatch`] / [`dispatch_typed`] / [`dispatch_owned`] — drive a Router with an envelope -//! 2. [`register_app`] / [`dispatch_from_json`] — global app factory -//! for any FFI boundary (JNI, C, WASM) +//! 1. **Direct API** — [`dispatch`] / [`dispatch_typed`] / +//! [`dispatch_owned`] drive a [`Router`] with a [`RequestEnvelope`] +//! and return a [`ResponseEnvelope`]. Bodies on this path are +//! UTF-8 text only; if the upstream response body is not valid +//! UTF-8 (binary content), [`ResponseEnvelope::body`] is the +//! empty string. Callers that need raw bytes must use the +//! binary wire API below. +//! +//! 2. **Binary wire API** — [`dispatch_from_bytes`] is the +//! zero-overhead FFI entry point. Wire format (request and +//! response use the same layout): +//! +//! ```text +//! bytes 0..4 : u32 BE = header_json byte length N +//! bytes 4..4+N : UTF-8 JSON +//! (request) { "v":1, "method", "path", +//! "query"?, "headers"? } +//! (response) { "v":1, "status", "headers", +//! "metadata" } +//! bytes 4+N..end : raw body bytes (UTF-8 text or binary — +//! no encoding applied) +//! ``` +//! +//! All failure modes return a valid wire-format response so the +//! caller's decoder never has to special-case errors. //! //! # Example (direct) //! //! ```ignore -//! let json = dispatch(router, &envelope).await; +//! let response = dispatch_typed(router, &envelope).await; //! ``` //! -//! # Example (FFI pattern) +//! # Example (binary wire / FFI) //! //! ```ignore //! // At init time (e.g. JNI_OnLoad, DllMain, _start) //! vespera_inprocess::register_app(|| create_app()); //! //! // On each FFI call -//! let response_json = vespera_inprocess::dispatch_from_json(request_json); +//! let response_bytes = +//! vespera_inprocess::dispatch_from_bytes(request_bytes, &runtime); //! ``` //! //! # Router caching semantics //! -//! [`register_app`] invokes the supplied factory **once** at registration -//! time and stores the resulting [`Router`]. Subsequent -//! [`dispatch_from_json`] calls reuse the cached router via -//! [`Router::clone`], which is cheap because axum's router is internally -//! `Arc`-shared. This avoids rebuilding the route tree on every FFI -//! request. -//! -//! [`dispatch_json_with`] retains the per-call factory contract for -//! tests that do not want global state. +//! [`register_app`] invokes the supplied factory **once** at +//! registration time and stores the resulting [`Router`]. Subsequent +//! [`dispatch_from_bytes`] calls reuse the cached router via +//! [`Router::clone`], which is cheap because axum's router is +//! internally `Arc`-shared. use std::collections::HashMap; use std::collections::hash_map::Entry; -use std::sync::OnceLock; +use std::convert::Infallible; +use std::pin::Pin; +use std::sync::{LazyLock, RwLock}; +use std::task::{Context, Poll}; use axum::body::Body; +use bytes::Bytes; use http::{Method, Request}; +use http_body::{Body as HttpBody, Frame}; use http_body_util::BodyExt; use serde::{Deserialize, Serialize}; use tower::ServiceExt; @@ -49,9 +74,22 @@ use tower::ServiceExt; /// Re-export `axum::Router` so consumers don't need a direct axum dependency. pub use axum::Router; +/// Wire format protocol version. The JSON header's `v` field MUST +/// equal this for requests; responses always emit this value. +const WIRE_VERSION: u8 = 1; + +/// Canonical name of the default app — used when the wire header +/// omits `"app"` or sets it to an empty string, and when callers use +/// the BC [`register_app`] entry point. +pub const DEFAULT_APP_NAME: &str = "_default"; + +/// Maximum allowed length of an app name (after trimming). Sized so +/// names fit comfortably in URL path segments and log lines. +const MAX_APP_NAME_LEN: usize = 64; + // ── Envelope Types ─────────────────────────────────────────────────── -/// Inbound request envelope. +/// Inbound request envelope (direct-API path). #[derive(Debug, Default, Clone, Deserialize)] pub struct RequestEnvelope { pub method: String, @@ -79,25 +117,81 @@ pub struct ResponseMetadata { } /// Outbound response envelope. +/// +/// `body` carries the response body decoded as UTF-8 text. For +/// binary responses that are not valid UTF-8, `body` will be the +/// empty string — callers that need raw bytes must use the binary +/// wire path ([`dispatch_from_bytes`]) instead of [`dispatch_typed`] +/// / [`dispatch_owned`]. #[derive(Debug, Serialize)] pub struct ResponseEnvelope { pub status: u16, pub headers: HashMap, + /// UTF-8 text body. Empty when the upstream response body is not + /// valid UTF-8 (binary responses). Use the binary wire path for + /// faithful byte round-trips. pub body: String, pub metadata: ResponseMetadata, } -// ── Dispatch (direct) ──────────────────────────────────────────────── +// ── Wire Format Types (internal) ───────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct WireRequestHeader { + /// Wire protocol version; clients MUST send 1. + #[serde(default)] + v: u8, + method: String, + path: String, + #[serde(default)] + query: String, + #[serde(default)] + headers: HashMap, + /// Optional name of the target app for multi-app routing. When + /// omitted (or empty), the request is dispatched to the default + /// app registered via [`register_app`]. Use [`register_app_named`] + /// to register additional named apps. + #[serde(default)] + app: Option, +} + +#[derive(Debug, Serialize)] +struct WireResponseHeader<'a> { + v: u8, + status: u16, + headers: &'a HashMap, + metadata: &'a ResponseMetadata, + /// Validation errors hoisted from a 422 JSON body so Java decoders + /// can read them with a single header parse. `None` for any other + /// status; the original body is preserved verbatim regardless. + #[serde(skip_serializing_if = "Option::is_none")] + validation_errors: Option>, +} + +/// One entry in the wire header's `validation_errors` array. Fields +/// are best-effort: missing values in the source body become `None`. +#[derive(Debug, Serialize)] +struct ValidationErrorItem { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +// ── Dispatch (direct API — backward compatible) ────────────────────── /// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and /// return the serialised [`ResponseEnvelope`] JSON. /// -/// This borrows the envelope and clones its owned fields before passing -/// them to the hot path. Callers that already own a [`RequestEnvelope`] -/// should prefer [`dispatch_owned`] to skip the clone. +/// This borrows the envelope and clones its owned fields before +/// passing them to the hot path. Callers that already own a +/// [`RequestEnvelope`] should prefer [`dispatch_owned`] to skip the +/// clone. pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { let result = dispatch_owned(router, envelope.clone()).await; - serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") + serde_json::to_string(&result) + .expect("ResponseEnvelope serialization is infallible") } /// Typed dispatch — returns a [`ResponseEnvelope`] directly. @@ -108,23 +202,35 @@ pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> Respo dispatch_owned(router, envelope.clone()).await } -/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into the -/// HTTP request so the body, path, and headers are never cloned. +/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into +/// the HTTP request so the body, path, and headers are never cloned. /// -/// This is the hot path used by [`dispatch_from_json`] / -/// [`dispatch_json_with`] and is exported for callers (e.g. custom FFI -/// transports) that already own a freshly parsed envelope. +/// This is the hot path used by callers (e.g. custom FFI transports) +/// that already own a freshly built envelope. pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { - dispatch_inner(router, envelope).await -} - -/// Parse a JSON string into a [`RequestEnvelope`]. -/// -/// # Errors -/// -/// Returns a human-readable error message if the JSON is malformed. -pub fn parse_request(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| format!("invalid request envelope: {e}")) + let parts = match dispatch_parts( + router, + &envelope.method, + envelope.path, + envelope.query, + envelope.headers, + envelope.body.into_bytes(), + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + return ResponseEnvelope { + status, + headers: HashMap::new(), + body: msg, + metadata: ResponseMetadata { + version: env!("CARGO_PKG_VERSION").to_owned(), + }, + }; + } + }; + to_response_envelope_text(parts) } /// Build an error [`ResponseEnvelope`] with status 500. @@ -142,98 +248,362 @@ pub fn error_envelope(message: &str) -> ResponseEnvelope { // ── App Factory (shared FFI pattern) ───────────────────────────────── -static APP_ROUTER: OnceLock = OnceLock::new(); +/// Per-name router cache. Indexed by app name; the default app uses +/// [`DEFAULT_APP_NAME`] (`"_default"`). +/// +/// Uses [`RwLock`] (not [`OnceLock`]) so multiple named apps can be +/// registered after init time, while keeping dispatch reads +/// contention-free. The map is read on every dispatch and written +/// only during `register_app*` calls (typically at process startup). +/// +/// Lock poisoning recovery: every read path uses +/// `unwrap_or_else(|e| e.into_inner())` so a panic in a producer +/// thread does not lock out the dispatch hot path. Factory closures +/// are also invoked **outside** the write lock so a factory panic +/// cannot poison the map. +static APP_ROUTERS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Validate an app name for registration / lookup. +/// +/// Constraints: +/// - non-empty after trimming whitespace +/// - at most [`MAX_APP_NAME_LEN`] bytes +/// - ASCII alphanumeric, `_`, or `-` only +/// +/// Returns the trimmed name on success. +fn validate_app_name(name: &str) -> Result<&str, String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("app name must not be empty".to_owned()); + } + if trimmed.len() > MAX_APP_NAME_LEN { + return Err(format!( + "app name too long: {} chars (max {MAX_APP_NAME_LEN})", + trimmed.len() + )); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "app name '{trimmed}' contains invalid characters (allowed: alphanumeric, '_', '-')" + )); + } + Ok(trimmed) +} -/// Register a global router factory. +/// Register the **default** global router factory. /// -/// Any FFI boundary (JNI, C, WASM) calls this once at init time, -/// then uses [`dispatch_from_json`] on each request. +/// Equivalent to `register_app_named(DEFAULT_APP_NAME, factory)`. +/// Wire requests without an `"app"` header (or with `"app": ""`) are +/// routed here. /// -/// The factory is invoked **once** at registration time; the resulting -/// [`Router`] is cached and cheaply cloned on every dispatch. Callers -/// that need to rebuild the router (e.g. for dev-only hot reload) must -/// instead pass a factory directly to [`dispatch_json_with`]. +/// Any FFI boundary (JNI, C, WASM) calls this once at init time, then +/// uses [`dispatch_from_bytes`] on each request. /// /// # Second-call semantics /// -/// If `register_app` has already been called in this process the second -/// (and later) calls are a **no-op** — the originally registered router -/// is preserved and the new `factory` closure is **not invoked**. This -/// is friendlier to environments that legitimately load the cdylib twice -/// (test harnesses that re-init the global, hot-reloading JVM hosts, -/// dynamic plugin systems) than the previous panic-on-double-call -/// behaviour. Because the new factory is never invoked, it is safe for -/// the closure to perform expensive or strictly-once work — that work -/// will not be repeated. +/// Calling `register_app` more than once is a **no-op** — the first +/// registration wins, the new factory closure is NOT invoked. Friendly +/// for environments that legitimately load the cdylib twice (hot-reloading +/// JVM hosts, plugin systems). pub fn register_app(factory: F) where F: Fn() -> Router + Send + Sync + 'static, { - // Short-circuit if already registered. Avoids running `factory()` - // a second time only to drop its result. - if APP_ROUTER.get().is_some() { - return; + register_app_named(DEFAULT_APP_NAME, factory); +} + +/// Register a **named** global router factory for multi-app routing. +/// +/// Wire requests carrying `"app": ""` in their header are +/// dispatched to this router. Multiple named apps can coexist in +/// the same process; register each once at init time. +/// +/// # First-wins per name +/// +/// Calling this more than once with the same `name` is a no-op — the +/// first registration wins. Registering different names is the +/// supported multi-app pattern. +/// +/// # Panic safety +/// +/// The `factory` closure is invoked **outside** the internal +/// `RwLock`'s write guard. A panic in `factory` cannot poison the +/// map; the registration is simply discarded and the slot remains +/// available for retry. +/// +/// # Invalid names +/// +/// Names that fail [`validate_app_name`] (empty, > 64 bytes, or +/// containing characters outside `[A-Za-z0-9_-]`) are silently +/// discarded — registration is a no-op. Dispatch with a matching +/// invalid name will return a `400` wire response. +pub fn register_app_named(name: &str, factory: F) +where + F: Fn() -> Router + Send + Sync + 'static, +{ + let name = match validate_app_name(name) { + Ok(n) => n.to_owned(), + Err(_) => return, + }; + // Fast path: existence check under a read lock. + { + let map = APP_ROUTERS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if map.contains_key(&name) { + return; + } } + // Build the router OUTSIDE the write lock so a panicking factory + // cannot poison the map. let router = factory(); - // `set` may still return `Err` if another thread won the race - // between the `get` above and here; that is also a no-op — the - // winning registration is preserved. - let _ = APP_ROUTER.set(router); + let mut map = APP_ROUTERS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Double-check: another thread may have inserted between our read + // and write. First-wins still holds — use Entry to avoid the + // map.contains_key + map.insert double lookup. + map.entry(name).or_insert(router); +} + +/// Resolve a [`Router`] for a wire request, applying default-app +/// fallback and name validation. Returns the cloned router (cheap — +/// axum's router is `Arc`-backed) on success, or a wire error response +/// (`400` for invalid name, `404` for unregistered name) on failure. +fn resolve_app_router(header: &WireRequestHeader) -> Result> { + let raw = header + .app + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_APP_NAME); + let name = match validate_app_name(raw) { + Ok(n) => n, + Err(msg) => return Err(error_wire(400, &format!("invalid app name: {msg}"))), + }; + let map = APP_ROUTERS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + map.get(name).cloned().ok_or_else(|| { + error_wire( + 404, + &format!( + "no app registered with name '{name}' — \ + use register_app() for the default app or \ + register_app_named(name, factory) for additional apps" + ), + ) + }) +} + +// ── Binary Wire API ────────────────────────────────────────────────── + +/// Dispatch a wire-format request through the registered app and +/// return a wire-format response. +/// +/// Wire format: +/// ```text +/// bytes 0..4 : u32 BE = header_json byte length N +/// bytes 4..4+N : UTF-8 JSON +/// (request) { "v":1, "method", "path", +/// "query"?, "headers"? } +/// (response) { "v":1, "status", "headers", +/// "metadata" } +/// bytes 4+N..end : raw body bytes (UTF-8 text or binary — +/// no encoding applied) +/// ``` +/// +/// All failure modes return a valid wire-format response (length- +/// prefixed) so the caller's decoder never has to special-case +/// errors. Specifically: +/// +/// * input shorter than 4 bytes → 400 with explanatory body +/// * `header_len` exceeds input → 400 +/// * header JSON parse failure → 400 +/// * wire version mismatch → 400 +/// * unknown HTTP method → 405 +/// * no app registered → 500 +/// * router/handler errors → surfaced verbatim as response wire +pub fn dispatch_from_bytes(input: Vec, runtime: &tokio::runtime::Runtime) -> Vec { + runtime.block_on(dispatch_from_bytes_async(input)) } -/// Dispatch a JSON request string through the registered app. +/// **Streaming** sibling of [`dispatch_from_bytes_async`]. /// -/// Returns a JSON response envelope string. Requires a tokio runtime -/// on the current thread (the caller provides it — e.g. JNI crate -/// uses a `LazyLock`). -pub fn dispatch_from_json(input: &str, runtime: &tokio::runtime::Runtime) -> String { - let Some(router) = APP_ROUTER.get() else { - return serialize_error("no app registered — call register_app() at init time"); +/// Drives the dispatch end-to-end like the non-streaming variant but +/// emits the response body **chunk-by-chunk via `on_chunk`** instead +/// of materialising it in a single `Vec`. Returns the wire-format +/// header bytes only (`[u32 BE header_len | header JSON]`) — the body +/// is delivered through the callback while the dispatch is in flight, +/// so a 1 GiB response is never resident in memory. +/// +/// `on_chunk` is invoked one or more times in arrival order; the +/// borrowed slice is valid only for the duration of each call and the +/// callback should treat it as ephemeral (e.g. write it to an +/// `OutputStream`, accumulate it on disk, …). +/// +/// Failure modes are identical to [`dispatch_from_bytes_async`] — +/// returns a valid wire-format error response (header + body) when +/// the wire input is malformed, the version is wrong, no app is +/// registered, or the handler reports a pre-dispatch error. In the +/// error path the body is included inside the returned bytes (not +/// streamed via `on_chunk`) because the error message is small. +/// +/// `on_chunk` is NOT called if the response body is empty. +pub async fn dispatch_streaming_async(input: Vec, mut on_chunk: F) -> Vec +where + F: FnMut(&[u8]), +{ + let (header, body_bytes) = match parse_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), }; - match parse_request(input) { - Ok(envelope) => { - let response = runtime.block_on(dispatch_owned(router.clone(), envelope)); - serde_json::to_string(&response).expect("ResponseEnvelope serialization is infallible") - } - Err(msg) => serialize_error(&msg), + if header.v != WIRE_VERSION { + return error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + ); } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => return wire, + }; + let (status, headers, metadata) = match dispatch_response_streaming( + router, + &header.method, + header.path, + header.query, + header.headers, + body_bytes, + &mut on_chunk, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + // Emit header-only wire bytes; body was streamed via on_chunk. + let header_view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &headers, + metadata: &metadata, + // Streaming path does not hoist 422 validation errors — + // hoisting requires materialising the full body, which is + // antithetical to the streaming contract. Callers needing + // validation hoisting should use dispatch_from_bytes_async. + validation_errors: None, + }; + let header_json = + serde_json::to_vec(&header_view).expect("WireResponseHeader serialization is infallible"); + let header_len = + u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); + let mut out = Vec::with_capacity(4 + header_json.len()); + out.extend_from_slice(&header_len.to_be_bytes()); + out.extend_from_slice(&header_json); + out } -/// Dispatch with an explicit factory — fully testable without global state. +/// Async sibling of [`dispatch_from_bytes`]. Use this when the caller +/// is already inside a Tokio runtime (e.g. an axum handler embedding +/// another vespera router, or a tokio-spawned task in the JNI bridge's +/// async dispatch path). /// -/// The factory is invoked on every call. For the cached-router path -/// used by FFI dispatch, see [`dispatch_from_json`]. -pub fn dispatch_json_with( - input: &str, - runtime: &tokio::runtime::Runtime, - factory: &dyn Fn() -> Router, -) -> String { - match parse_request(input) { - Ok(envelope) => { - let response = runtime.block_on(dispatch_owned(factory(), envelope)); - serde_json::to_string(&response).expect("ResponseEnvelope serialization is infallible") - } - Err(msg) => serialize_error(&msg), +/// All failure modes return a valid wire-format response (same +/// guarantees as [`dispatch_from_bytes`]), including `500` when no app +/// is registered. +pub async fn dispatch_from_bytes_async(input: Vec) -> Vec { + // Wire-level checks first: malformed input must report parse + // errors regardless of whether an app is registered. + let (header, body_bytes) = match parse_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + if header.v != WIRE_VERSION { + return error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + ); } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => return wire, + }; + let parts = match dispatch_parts( + router, + &header.method, + header.path, + header.query, + header.headers, + body_bytes, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + to_wire_bytes(parts) } -/// Serialize an error envelope to JSON. -pub fn serialize_error(msg: &str) -> String { - serde_json::to_string(&error_envelope(msg)).expect("error_envelope serialization is infallible") +/// Build a wire-format error response with a plain-text body. +/// +/// Used by [`dispatch_from_bytes`] for malformed input and by the +/// JNI bridge for panic fallback. The response always carries +/// `content-type: text/plain; charset=utf-8`. +#[must_use] +pub fn error_wire(status: u16, msg: &str) -> Vec { + let mut headers = HashMap::new(); + headers.insert( + "content-type".to_owned(), + HeaderValue::Single("text/plain; charset=utf-8".to_owned()), + ); + let metadata = ResponseMetadata { + version: env!("CARGO_PKG_VERSION").to_owned(), + }; + let parts = ( + status, + headers, + Bytes::from(msg.as_bytes().to_vec()), + metadata, + ); + to_wire_bytes(parts) } -// ── Internal ───────────────────────────────────────────────────────── +// ── Internal Helpers ───────────────────────────────────────────────── -async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { - let version = env!("CARGO_PKG_VERSION").to_owned(); +type ResponseParts = (u16, HashMap, Bytes, ResponseMetadata); - let RequestEnvelope { - method, - path, - query, - headers, - body, - } = envelope; +/// Drive a [`Router`] with the supplied envelope fields and return +/// raw response parts. +/// +/// Returns `Err((status, msg))` only for pre-dispatch errors +/// (currently only "invalid HTTP method" → 405). Router/handler +/// errors cannot occur because axum routers are +/// `Service<_, Error = Infallible>`. +async fn dispatch_parts( + router: Router, + method_str: &str, + path: String, + query: String, + headers: HashMap, + body_bytes: Vec, +) -> Result { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; let uri = if query.is_empty() { path @@ -241,29 +611,65 @@ async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEn format!("{path}?{query}") }; - // Parse the HTTP method explicitly. Previously an invalid method - // (e.g. an empty string, whitespace, a malformed token) was - // silently coerced to `GET`, causing the router to dispatch the - // request to whichever handler happened to live at that path's GET - // route. That is a correctness footgun — a malformed method - // would return 200 from a GET handler instead of the expected - // method-not-allowed response. We now short-circuit with - // `405 Method Not Allowed` before the router is consulted. - // - // Note: well-formed but unknown methods (e.g. `BREW`) still reach - // the router and let axum produce the canonical 405 itself. - let Ok(http_method) = method.parse::() else { - return ResponseEnvelope { - status: 405, - headers: HashMap::new(), - body: format!("Method Not Allowed: '{method}' is not a valid HTTP method"), - metadata: ResponseMetadata { version }, - }; + // Case-insensitive Content-Type detection (RFC 7230 §3.2). + let has_content_type = headers + .keys() + .any(|k| k.eq_ignore_ascii_case("content-type")); + + let mut builder = Request::builder().method(http_method).uri(&uri); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_str()); + } + if !body_bytes.is_empty() && !has_content_type { + builder = builder.header("content-type", "application/json"); + } + + let request = builder + .body(Body::from(body_bytes)) + .expect("request construction should not fail with valid URI"); + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + Ok(collect_response_parts(response).await) +} + +/// Drive a [`Router`] and stream response body chunks through +/// `on_chunk`, returning the status/headers/metadata once the body +/// stream finishes. +/// +/// Same pre-dispatch error semantics as [`dispatch_parts`] (invalid +/// HTTP method → `Err((405, ...))`). Body stream errors are silently +/// ended (the consumer sees a truncated response) because they +/// indicate the upstream handler aborted; the headers/status that +/// were already collected remain accurate. +async fn dispatch_response_streaming( + router: Router, + method_str: &str, + path: String, + query: String, + headers: HashMap, + body_bytes: Vec, + on_chunk: &mut F, +) -> Result<(u16, HashMap, ResponseMetadata), (u16, String)> +where + F: FnMut(&[u8]), +{ + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + + let uri = if query.is_empty() { + path + } else { + format!("{path}?{query}") }; - // Case-insensitive Content-Type detection (RFC 7230 §3.2 — header - // names are case-insensitive). Avoids double-injecting application/json - // when callers send "Content-Type" or "CONTENT-TYPE". let has_content_type = headers .keys() .any(|k| k.eq_ignore_ascii_case("content-type")); @@ -272,12 +678,12 @@ async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEn for (name, value) in &headers { builder = builder.header(name.as_str(), value.as_str()); } - if !body.is_empty() && !has_content_type { + if !body_bytes.is_empty() && !has_content_type { builder = builder.header("content-type", "application/json"); } let request = builder - .body(Body::from(body)) + .body(Body::from(body_bytes)) .expect("request construction should not fail with valid URI"); let response = router @@ -285,11 +691,9 @@ async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEn .await .expect("router error is Infallible"); + let version = env!("CARGO_PKG_VERSION").to_owned(); let status = response.status().as_u16(); - // Single-pass response header conversion: collapse repeated header - // names into HeaderValue::Multi without an intermediate - // HashMap>. let mut resp_headers: HashMap = HashMap::with_capacity(response.headers().len()); for (name, value) in response.headers() { @@ -312,22 +716,552 @@ async fn dispatch_inner(router: Router, envelope: RequestEnvelope) -> ResponseEn } } - // Body decode: avoid `Bytes -> Vec -> String` indirection. - // `from_utf8_lossy` borrows the bytes; if they are valid UTF-8 the - // owned String is allocated once. Invalid sequences are replaced - // with U+FFFD instead of being silently dropped to an empty string, - // which surfaces non-UTF-8 responses to callers. For true binary - // payloads, an additive `body_bytes` field on `ResponseEnvelope` - // remains a follow-up. - let body_str = response.into_body().collect().await.map_or_else( - |_| String::new(), - |collected| String::from_utf8_lossy(&collected.to_bytes()).into_owned(), - ); + // Stream body chunks: pull frames one at a time and surface only + // data frames (trailers are dropped — wire format does not carry + // them). Frame errors or end-of-stream both terminate cleanly. + let mut body = response.into_body(); + while let Some(Ok(frame)) = body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } + + Ok(( + status, + resp_headers, + ResponseMetadata { version }, + )) +} +/// Collect status, headers, body bytes, and metadata from an axum +/// response. Headers with repeated names are collapsed into +/// [`HeaderValue::Multi`] so semantics (e.g. `set-cookie`) are +/// preserved. +async fn collect_response_parts(response: axum::response::Response) -> ResponseParts { + let version = env!("CARGO_PKG_VERSION").to_owned(); + let status = response.status().as_u16(); + + let mut resp_headers: HashMap = + HashMap::with_capacity(response.headers().len()); + for (name, value) in response.headers() { + let val_str = value.to_str().unwrap_or("").to_owned(); + match resp_headers.entry(name.as_str().to_owned()) { + Entry::Vacant(e) => { + e.insert(HeaderValue::Single(val_str)); + } + Entry::Occupied(mut e) => { + let slot = e.get_mut(); + let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { + HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), + HeaderValue::Multi(mut v) => { + v.push(val_str); + HeaderValue::Multi(v) + } + }; + *slot = new_slot; + } + } + } + + let body_bytes = response + .into_body() + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(); + + ( + status, + resp_headers, + body_bytes, + ResponseMetadata { version }, + ) +} + +/// Adapter: response parts → text envelope. Non-UTF-8 bodies become +/// the empty string. +fn to_response_envelope_text(parts: ResponseParts) -> ResponseEnvelope { + let (status, headers, body_bytes, metadata) = parts; + let body = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); ResponseEnvelope { status, - headers: resp_headers, - body: body_str, - metadata: ResponseMetadata { version }, + headers, + body, + metadata, + } +} + +/// Adapter: response parts → wire-format bytes. Layout: +/// `[u32 BE header_len | JSON header | raw body]`. +/// +/// For `status == 422` JSON responses we **best-effort** hoist any +/// `{"errors": [...]}` payload into the wire header's +/// `validation_errors` field — Java decoders can read validation +/// failures with a single header parse, while the original body is +/// preserved verbatim for clients that still rely on it. +fn to_wire_bytes(parts: ResponseParts) -> Vec { + let (status, headers, body_bytes, metadata) = parts; + let validation_errors = if status == 422 { + try_hoist_validation_errors(&headers, &body_bytes) + } else { + None + }; + let header = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &headers, + metadata: &metadata, + validation_errors, + }; + let header_json = + serde_json::to_vec(&header).expect("WireResponseHeader serialization is infallible"); + let header_len = + u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); + let mut out = Vec::with_capacity(4 + header_json.len() + body_bytes.len()); + out.extend_from_slice(&header_len.to_be_bytes()); + out.extend_from_slice(&header_json); + out.extend_from_slice(&body_bytes); + out +} + +/// Dispatch a request and split the response into +/// `(status, headers, metadata, body)` — exposing `axum::body::Body` +/// so callers can stream it themselves (vs. collecting it eagerly). +/// +/// Used by the `*_with_header` streaming variants which need to emit +/// the wire-format header **before** body bytes start flowing. +async fn dispatch_and_split( + router: Router, + method_str: &str, + path: String, + query: String, + headers: HashMap, + body: Body, +) -> Result< + (u16, HashMap, ResponseMetadata, Body), + (u16, String), +> { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + + let uri = if query.is_empty() { + path + } else { + format!("{path}?{query}") + }; + + let mut builder = Request::builder().method(http_method).uri(&uri); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_str()); + } + + let request = builder + .body(body) + .expect("request construction should not fail with valid URI"); + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + let version = env!("CARGO_PKG_VERSION").to_owned(); + let status = response.status().as_u16(); + + let mut resp_headers: HashMap = + HashMap::with_capacity(response.headers().len()); + for (name, value) in response.headers() { + let val_str = value.to_str().unwrap_or("").to_owned(); + match resp_headers.entry(name.as_str().to_owned()) { + Entry::Vacant(e) => { + e.insert(HeaderValue::Single(val_str)); + } + Entry::Occupied(mut e) => { + let slot = e.get_mut(); + let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { + HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), + HeaderValue::Multi(mut v) => { + v.push(val_str); + HeaderValue::Multi(v) + } + }; + *slot = new_slot; + } + } + } + + let body = response.into_body(); + Ok((status, resp_headers, ResponseMetadata { version }, body)) +} + +/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) +/// without a body — used by the `*_with_header` callback variants. +fn build_wire_header_bytes( + status: u16, + headers: &HashMap, + metadata: &ResponseMetadata, +) -> Vec { + let view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers, + metadata, + validation_errors: None, + }; + let header_json = + serde_json::to_vec(&view).expect("WireResponseHeader serialization is infallible"); + let header_len = + u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); + let mut out = Vec::with_capacity(4 + header_json.len()); + out.extend_from_slice(&header_len.to_be_bytes()); + out.extend_from_slice(&header_json); + out +} + +/// **Streaming dispatch with explicit header callback** — emits the +/// wire-format response header via `on_header` **before** any body +/// chunk is delivered to `on_chunk`. +/// +/// This is the variant Spring `HttpServletResponse`-based controllers +/// want: `on_header` fires while the response is still uncommitted, +/// so the controller can call `resp.setStatus(...)` / +/// `resp.setHeader(...)` from the callback. Then `on_chunk` streams +/// the body bytes one frame at a time. +/// +/// `on_header` is called **exactly once** in every code path — +/// success or error. On error (malformed wire, no app, invalid +/// method, …) the bytes passed to `on_header` are a normal +/// `error_wire(...)` response and `on_chunk` is **not** invoked. +pub async fn dispatch_streaming_with_header_async( + input: Vec, + mut on_header: H, + mut on_chunk: F, +) where + H: FnMut(&[u8]), + F: FnMut(&[u8]), +{ + let (header, body_bytes) = match parse_wire_request(input) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + if header.v != WIRE_VERSION { + on_header(&error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + )); + return; + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => { + on_header(&wire); + return; + } + }; + + let (status, headers, metadata, mut body) = match dispatch_and_split( + router, + &header.method, + header.path, + header.query, + header.headers, + Body::from(body_bytes), + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + on_header(&error_wire(status, &msg)); + return; + } + }; + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + + while let Some(Ok(frame)) = body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } +} + +/// Best-effort extract validation errors from a 422 JSON body. +/// +/// Returns `None` (silently) for: +/// - non-JSON content-types (anything that doesn't end in `/json` or +/// `+json`) +/// - body bytes that don't parse as JSON +/// - JSON without an `errors` array, or with an empty array +/// +/// This is intentionally lenient — a malformed 422 body must never +/// degrade to a 5xx; the original body is still surfaced verbatim. +fn try_hoist_validation_errors( + headers: &HashMap, + body_bytes: &Bytes, +) -> Option> { + let is_json = headers.iter().any(|(k, v)| { + if !k.eq_ignore_ascii_case("content-type") { + return false; + } + let s = match v { + HeaderValue::Single(s) => s.as_str(), + HeaderValue::Multi(vs) => vs.first().map_or("", String::as_str), + }; + let mime = s + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + mime == "application/json" || mime.ends_with("+json") + }); + if !is_json { + return None; + } + let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; + let errors = parsed.get("errors")?.as_array()?; + let items: Vec = errors + .iter() + .filter_map(|e| { + let path = e.get("path")?.as_str()?.to_owned(); + let code = e + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let message = e + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + Some(ValidationErrorItem { + path, + code, + message, + }) + }) + .collect(); + if items.is_empty() { None } else { Some(items) } +} + +/// **Bidirectional streaming dispatch** — both request and response +/// bodies are streamed chunk-by-chunk; neither side materialises the +/// full payload in memory. +/// +/// - `input_header` is a wire-format request **without a body** +/// (just `[u32 BE header_len | JSON header]`). Send the body +/// chunks via `pull_chunk`, not embedded in this buffer. +/// - `pull_chunk` is called repeatedly to obtain request body +/// chunks. Return `Some(chunk)` for each chunk and `None` to +/// signal EOF. An empty `Some(Vec::new())` is treated as +/// "no more data right now, but keep the stream open" — rarely +/// useful; most callers should just return `None`. +/// - `on_chunk` receives response body chunks in arrival order, same +/// contract as [`dispatch_streaming_async`]. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`) — the response body was delivered via `on_chunk`. +/// +/// `pull_chunk` runs on a Tokio blocking thread (`spawn_blocking`) +/// because the JNI implementation reads from a Java `InputStream`, +/// which is inherently blocking. Backpressure is enforced by a +/// bounded 16-slot mpsc channel: if axum reads slowly, the +/// `pull_chunk` call blocks naturally. +/// +/// Failure modes match [`dispatch_streaming_async`]: malformed +/// header / unknown version / no app / handler error → normal +/// `error_wire(...)` response (with the message inside the returned +/// bytes); neither callback is invoked in those paths. +pub async fn dispatch_bidirectional_streaming( + input_header: Vec, + pull_chunk: P, + on_chunk: F, +) -> Vec +where + P: FnMut() -> Option> + Send + 'static, + F: FnMut(&[u8]), +{ + let mut header_bytes: Vec = Vec::new(); + { + let on_header = |h: &[u8]| header_bytes.extend_from_slice(h); + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; + } + header_bytes +} + +/// **Bidirectional streaming with explicit header callback** — the +/// `with_header` counterpart of [`dispatch_bidirectional_streaming`]. +/// Emits the wire-format response header via `on_header` **before** +/// any response body byte reaches `on_chunk`, so Spring-style +/// `HttpServletResponse` controllers can commit status / headers +/// from the callback while the response is still uncommitted. +/// +/// `on_header` is called exactly once on every code path (success or +/// error). On any pre-dispatch / wire error the bytes passed to +/// `on_header` are a normal `error_wire(...)` response and neither +/// `pull_chunk` nor `on_chunk` is invoked beyond that point. +pub async fn dispatch_bidirectional_streaming_with_header( + input_header: Vec, + pull_chunk: P, + on_chunk: F, + on_header: H, +) where + P: FnMut() -> Option> + Send + 'static, + F: FnMut(&[u8]), + H: FnMut(&[u8]), +{ + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; +} + +async fn bidirectional_streaming_inner( + input_header: Vec, + pull_chunk: P, + mut on_chunk: F, + mut on_header: H, +) where + P: FnMut() -> Option> + Send + 'static, + F: FnMut(&[u8]), + H: FnMut(&[u8]), +{ + let (header, _ignored_body) = match parse_wire_request(input_header) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + if header.v != WIRE_VERSION { + on_header(&error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + )); + return; + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => { + on_header(&wire); + return; + } + }; + + // Bounded 16-slot mpsc — gives natural backpressure between the + // pull_chunk producer thread and the axum handler consumer. + let (tx, rx) = tokio::sync::mpsc::channel::(16); + + let producer_handle = tokio::task::spawn_blocking(move || { + let mut pull = pull_chunk; + loop { + match pull() { + // Ignore empty Some(chunk) — only None signals EOF. + Some(chunk) if chunk.is_empty() => {} + Some(chunk) => { + if tx.blocking_send(Bytes::from(chunk)).is_err() { + // Receiver (axum body) dropped — handler aborted + // mid-stream; stop pulling. + break; + } + } + None => break, // EOF + } + } + // tx dropped at end of scope → axum sees end-of-stream. + }); + + let body = Body::new(ChannelBody { rx }); + let (status, headers, metadata, mut response_body) = match dispatch_and_split( + router.clone(), + &header.method, + header.path, + header.query, + header.headers, + body, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + let _ = producer_handle.await; + on_header(&error_wire(status, &msg)); + return; + } + }; + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + + while let Some(Ok(frame)) = response_body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } + + let _ = producer_handle.await; +} + +/// Minimal `http_body::Body` implementation backed by an mpsc +/// `Receiver` — used by [`dispatch_bidirectional_streaming`] +/// to feed request body chunks into axum. +struct ChannelBody { + rx: tokio::sync::mpsc::Receiver, +} + +impl HttpBody for ChannelBody { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + match self.rx.poll_recv(cx) { + Poll::Ready(Some(bytes)) => Poll::Ready(Some(Ok(Frame::data(bytes)))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + + + +/// Parse a wire-format request. On success returns the deserialised +/// header and the owned body bytes (zero-copy via `Vec::split_off`). +fn parse_wire_request(mut input: Vec) -> Result<(WireRequestHeader, Vec), String> { + if input.len() < 4 { + return Err(format!( + "wire input too short: {} bytes, need at least 4", + input.len() + )); + } + let mut len_bytes = [0u8; 4]; + len_bytes.copy_from_slice(&input[..4]); + let header_len = u32::from_be_bytes(len_bytes) as usize; + let total_header_end = 4usize.saturating_add(header_len); + if total_header_end > input.len() { + return Err(format!( + "wire header_len ({header_len}) exceeds remaining input ({} bytes)", + input.len() - 4 + )); } + // Take ownership of the body without copy. + let body = input.split_off(total_header_end); + let header_json = &input[4..total_header_end]; + let header: WireRequestHeader = serde_json::from_slice(header_json) + .map_err(|e| format!("wire header JSON parse error: {e}"))?; + Ok((header, body)) } diff --git a/crates/vespera_inprocess/tests/binary_wire.rs b/crates/vespera_inprocess/tests/binary_wire.rs new file mode 100644 index 0000000..209bd6b --- /dev/null +++ b/crates/vespera_inprocess/tests/binary_wire.rs @@ -0,0 +1,448 @@ +//! Round-trip integration tests for the binary wire format +//! ([`vespera_inprocess::dispatch_from_bytes`]). +//! +//! Each test builds a wire-format request, dispatches it through a +//! dedicated test router (registered exactly once via `register_app` +//! by the FIRST test in this binary to run), and asserts on the +//! decoded wire response. The wire format is: +//! +//! ```text +//! [u32 BE header_len][UTF-8 JSON header][raw body bytes] +//! ``` + +use std::collections::HashMap; +use std::sync::Once; + +use axum::Router; +use axum::extract::Query; +use axum::http::HeaderMap; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use bytes::Bytes; +use serde::Deserialize; +use serde_json::Value; +use std::sync::Mutex; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, register_app}; + +// ── Test app ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct PingQuery { + msg: Option, +} + +async fn ping() -> &'static str { + "pong" +} + +async fn echo_text(body: String) -> String { + body +} + +async fn echo_bytes(headers: HeaderMap, body: Bytes) -> Response { + let ct = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_owned(); + ([(header::CONTENT_TYPE, ct)], body).into_response() +} + +async fn query(Query(q): Query) -> String { + q.msg.unwrap_or_default() +} + +async fn multi_header() -> Response { + use axum::http::HeaderName; + let mut headers = HeaderMap::new(); + let name = HeaderName::from_static("set-cookie"); + headers.append(name.clone(), "a=1".parse().unwrap()); + headers.append(name, "b=2".parse().unwrap()); + (headers, "ok").into_response() +} + +fn test_router() -> Router { + Router::new() + .route("/ping", get(ping)) + .route("/echo/text", post(echo_text)) + .route("/echo/bytes", post(echo_bytes)) + .route("/query", get(query)) + .route("/multi", get(multi_header)) +} + +fn install_router() { + static INIT: Once = Once::new(); + INIT.call_once(|| register_app(test_router)); +} + +// ── Wire helpers ───────────────────────────────────────────────────── + +fn encode_wire( + method: &str, + path: &str, + query: Option<&str>, + headers: HashMap<&str, &str>, + body: &[u8], +) -> Vec { + let headers_json: serde_json::Map = headers + .into_iter() + .map(|(k, v)| (k.to_owned(), Value::String(v.to_owned()))) + .collect(); + let mut header = serde_json::Map::new(); + header.insert("v".to_owned(), Value::from(1u8)); + header.insert("method".to_owned(), Value::String(method.to_owned())); + header.insert("path".to_owned(), Value::String(path.to_owned())); + if let Some(q) = query { + header.insert("query".to_owned(), Value::String(q.to_owned())); + } + if !headers_json.is_empty() { + header.insert("headers".to_owned(), Value::Object(headers_json)); + } + let header_bytes = serde_json::to_vec(&Value::Object(header)).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +fn decode_wire(resp: &[u8]) -> (Value, Vec) { + assert!( + resp.len() >= 4, + "wire response too short ({})", + resp.len() + ); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len {header_len} overflows response ({} bytes)", + resp.len() + ); + let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) + .expect("response header JSON parses"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +fn dispatch(wire: Vec) -> (Value, Vec) { + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let resp = dispatch_from_bytes(wire, &runtime); + decode_wire(&resp) +} + +// ── Tests ──────────────────────────────────────────────────────────── + +#[test] +fn get_text_response_roundtrip() { + let (header, body) = dispatch(encode_wire("GET", "/ping", None, HashMap::new(), &[])); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(String::from_utf8_lossy(&body), "pong"); +} + +#[test] +fn post_json_body_echoes_back() { + let json = br#"{"foo":"bar"}"#; + let (header, body) = dispatch(encode_wire( + "POST", + "/echo/text", + None, + HashMap::from([("content-type", "application/json")]), + json, + )); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, json); +} + +#[test] +fn post_octet_stream_preserves_non_utf8_bytes() { + // Includes 0x00, 0xFF, and an invalid UTF-8 sequence (0xC0 0xC0). + let raw: Vec = vec![0x00, 0x01, 0x02, 0xC0, 0xC0, 0xFE, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF]; + let (header, body) = dispatch(encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &raw, + )); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, raw, "binary body must round-trip byte-for-byte"); +} + +#[test] +fn query_string_is_forwarded() { + let (header, body) = dispatch(encode_wire( + "GET", + "/query", + Some("msg=hello%20world"), + HashMap::new(), + &[], + )); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(String::from_utf8_lossy(&body), "hello world"); +} + +#[test] +fn multiple_set_cookie_headers_collapse_to_multi() { + let (header, _body) = dispatch(encode_wire("GET", "/multi", None, HashMap::new(), &[])); + assert_eq!(header["status"].as_u64(), Some(200)); + let set_cookie = &header["headers"]["set-cookie"]; + let arr = set_cookie + .as_array() + .unwrap_or_else(|| panic!("expected array for repeated set-cookie, got {set_cookie:#}")); + let values: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + assert!( + values.contains(&"a=1") && values.contains(&"b=2"), + "expected both cookies preserved, got {values:?}" + ); +} + +#[test] +fn unknown_path_returns_404() { + let (header, _body) = dispatch(encode_wire( + "GET", + "/does-not-exist", + None, + HashMap::new(), + &[], + )); + assert_eq!(header["status"].as_u64(), Some(404)); +} + +#[test] +fn invalid_http_method_returns_405() { + let (header, body) = dispatch(encode_wire( + "GET WITH SPACES", + "/ping", + None, + HashMap::new(), + &[], + )); + assert_eq!(header["status"].as_u64(), Some(405)); + assert!( + String::from_utf8_lossy(&body).contains("Method Not Allowed"), + "body should explain method invalidity" + ); +} + +#[test] +fn large_body_one_mib_roundtrips() { + let big: Vec = (0..1024u32 * 1024) + .map(|i| u8::try_from(i % 256).expect("mod 256 fits in u8")) + .collect(); + let (header, body) = dispatch(encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &big, + )); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body.len(), big.len(), "size match"); + assert_eq!(body, big, "1 MiB body must round-trip byte-for-byte"); +} + +#[test] +fn empty_body_request_returns_text_response() { + let (header, body) = dispatch(encode_wire("GET", "/ping", None, HashMap::new(), &[])); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"pong"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dispatch_from_bytes_async_inside_runtime() { + // The async API runs natively in a Tokio runtime without + // block_on — required by callers like the JNI dispatchAsync path. + install_router(); + let wire = encode_wire("GET", "/ping", None, HashMap::new(), &[]); + let resp = vespera_inprocess::dispatch_from_bytes_async(wire).await; + let (header, body) = decode_wire(&resp); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"pong"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dispatch_streaming_async_chunks_text_body() { + install_router(); + let wire = encode_wire("GET", "/ping", None, HashMap::new(), &[]); + let mut chunks: Vec> = Vec::new(); + let header_bytes = vespera_inprocess::dispatch_streaming_async(wire, |chunk| { + chunks.push(chunk.to_vec()); + }) + .await; + let (header, body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + assert!( + body.is_empty(), + "streaming response wire must carry no body (it goes via on_chunk)" + ); + let collected: Vec = chunks.into_iter().flatten().collect(); + assert_eq!(collected, b"pong"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dispatch_streaming_async_large_binary_body() { + install_router(); + let big_payload: Vec = (0u32..256 * 1024) + .map(|i| u8::try_from(i % 256).expect("mod 256")) + .collect(); + let wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &big_payload, + ); + let mut received: Vec = Vec::with_capacity(big_payload.len()); + let header_bytes = vespera_inprocess::dispatch_streaming_async(wire, |chunk| { + received.extend_from_slice(chunk); + }) + .await; + let (header, _body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!( + received, big_payload, + "256 KiB binary body must round-trip byte-for-byte via streaming callback" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_roundtrips_small_body() { + install_router(); + + // Wire request header (no body — body comes via pull_chunk). + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // Request body chunks to push. + let chunks: Vec> = vec![ + b"hello ".to_vec(), + b"world".to_vec(), + b"!".to_vec(), + ]; + let chunks_iter = Mutex::new(chunks.into_iter()); + let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + + // Response body sink. + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + }; + + let header_bytes = vespera_inprocess::dispatch_bidirectional_streaming( + header_only_wire, + pull_chunk, + on_chunk, + ) + .await; + + let (header, body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + assert!(body.is_empty(), "header-only response must carry no body"); + + let final_body = received.lock().unwrap().clone(); + assert_eq!( + String::from_utf8_lossy(&final_body), + "hello world!", + "request body chunks must roundtrip through the handler verbatim" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_large_request_body() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // 512 KiB request body split into ~16 KiB chunks — the size where + // the bounded mpsc channel (16 slots) will exercise backpressure. + let total_size = 512 * 1024; + let chunk_size = 16 * 1024; + let n_chunks = total_size / chunk_size; + let request_chunks: Vec> = (0..n_chunks) + .map(|i| { + (0..chunk_size) + .map(|j| u8::try_from((i * chunk_size + j) % 256).expect("mod 256")) + .collect() + }) + .collect(); + let expected: Vec = request_chunks.iter().flatten().copied().collect(); + let chunks_iter = Mutex::new(request_chunks.into_iter()); + let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + }; + + let header_bytes = vespera_inprocess::dispatch_bidirectional_streaming( + header_only_wire, + pull_chunk, + on_chunk, + ) + .await; + + let (header, _) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + + let final_body = received.lock().unwrap().clone(); + assert_eq!(final_body.len(), expected.len(), "size match"); + assert_eq!( + final_body, expected, + "512 KiB request body must round-trip byte-for-byte" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dispatch_bidirectional_streaming_emits_error_wire_on_malformed_header() { + install_router(); + let bad_header: Vec = vec![0u8, 0, 0, 99]; // overflow + let pull = || -> Option> { None }; + let on = |_: &[u8]| {}; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(bad_header, pull, on).await; + let (header, body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(400)); + assert!(!body.is_empty(), "error response carries explanatory body"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dispatch_streaming_async_emits_error_wire_on_malformed_input() { + install_router(); + let bad_wire: Vec = vec![0u8, 0, 0, 99]; // header_len overflow + let mut chunks: Vec> = Vec::new(); + let header_bytes = vespera_inprocess::dispatch_streaming_async(bad_wire, |chunk| { + chunks.push(chunk.to_vec()); + }) + .await; + // On error the streaming variant emits a normal error_wire — header + body + // both inside the returned bytes (no callback invocation). + let (header, body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(400)); + assert!( + !body.is_empty(), + "error response must carry the error message in its body" + ); + assert!(chunks.is_empty(), "no chunks should fire on malformed input"); +} diff --git a/crates/vespera_inprocess/tests/error_wire.rs b/crates/vespera_inprocess/tests/error_wire.rs new file mode 100644 index 0000000..7059f6a --- /dev/null +++ b/crates/vespera_inprocess/tests/error_wire.rs @@ -0,0 +1,49 @@ +//! Shape tests for [`vespera_inprocess::error_wire`]. +//! +//! Verifies that the helper used by the JNI bridge for panic / parse +//! fallback produces a self-consistent wire response that decoders can +//! always read. + +use serde_json::Value; +use vespera_inprocess::error_wire; + +fn decode(resp: &[u8]) -> (Value, Vec) { + assert!(resp.len() >= 4, "too short"); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!(4 + header_len <= resp.len(), "header_len overflows"); + let header: Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("header JSON"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +#[test] +fn error_wire_preserves_status_and_message() { + let (header, body) = decode(&error_wire(418, "I'm a teapot")); + assert_eq!(header["v"].as_u64(), Some(1)); + assert_eq!(header["status"].as_u64(), Some(418)); + assert_eq!(String::from_utf8(body).unwrap(), "I'm a teapot"); +} + +#[test] +fn error_wire_carries_text_plain_content_type() { + let (header, _body) = decode(&error_wire(500, "boom")); + let ct = header["headers"]["content-type"] + .as_str() + .expect("content-type header missing"); + assert_eq!(ct, "text/plain; charset=utf-8"); +} + +#[test] +fn error_wire_status_stable_across_range() { + for status in [400u16, 401, 422, 500, 502, 599] { + let (header, body) = decode(&error_wire(status, "msg")); + assert_eq!(header["status"].as_u64(), Some(u64::from(status))); + assert_eq!(body, b"msg"); + assert!( + !header["metadata"]["version"].as_str().unwrap().is_empty(), + "metadata.version must be populated" + ); + } +} diff --git a/crates/vespera_inprocess/tests/multi_app.rs b/crates/vespera_inprocess/tests/multi_app.rs new file mode 100644 index 0000000..9286815 --- /dev/null +++ b/crates/vespera_inprocess/tests/multi_app.rs @@ -0,0 +1,213 @@ +//! Integration tests for multi-app routing +//! ([`vespera_inprocess::register_app_named`]). +//! +//! Validates that multiple named axum routers can coexist in the +//! same process and dispatch correctly based on the wire header's +//! `"app"` field. + +use std::collections::HashMap; +use std::sync::Once; + +use axum::Router; +use axum::routing::get; +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, register_app, register_app_named}; + +fn admin_router() -> Router { + Router::new() + .route("/dashboard", get(|| async { "admin-dashboard" })) + .route("/users", get(|| async { "admin-users" })) +} + +fn public_router() -> Router { + Router::new() + .route("/health", get(|| async { "public-health" })) + .route("/about", get(|| async { "public-about" })) +} + +fn default_router() -> Router { + Router::new().route("/root", get(|| async { "default-root" })) +} + +/// Register all three apps exactly once per test binary. +fn install_all_apps() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(default_router); + register_app_named("admin", admin_router); + register_app_named("public", public_router); + }); +} + +fn encode_wire(method: &str, path: &str, app: Option<&str>) -> Vec { + let mut header = serde_json::Map::new(); + header.insert("v".to_owned(), Value::from(1u8)); + header.insert("method".to_owned(), Value::String(method.to_owned())); + header.insert("path".to_owned(), Value::String(path.to_owned())); + if let Some(a) = app { + header.insert("app".to_owned(), Value::String(a.to_owned())); + } + let header_bytes = + serde_json::to_vec(&Value::Object(header)).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire +} + +fn dispatch(wire: Vec) -> (Value, Vec) { + install_all_apps(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let resp = dispatch_from_bytes(wire, &runtime); + assert!(resp.len() >= 4); + let len_bytes: [u8; 4] = resp[..4].try_into().unwrap(); + let header_len = u32::from_be_bytes(len_bytes) as usize; + let header: Value = serde_json::from_slice(&resp[4..4 + header_len]).unwrap(); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +#[test] +fn default_app_reachable_when_app_omitted() { + let (header, body) = dispatch(encode_wire("GET", "/root", None)); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"default-root"); +} + +#[test] +fn default_app_reachable_with_explicit_default_name() { + let (header, body) = dispatch(encode_wire("GET", "/root", Some("_default"))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"default-root"); +} + +#[test] +fn empty_app_name_falls_back_to_default() { + // Empty string should be treated the same as omission, not as an + // invalid name (matches Oracle Q7 recommendation). + let (header, body) = dispatch(encode_wire("GET", "/root", Some(""))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"default-root"); +} + +#[test] +fn admin_app_routes_isolated_from_public_app() { + // /dashboard exists only on the admin app + let (header, body) = dispatch(encode_wire("GET", "/dashboard", Some("admin"))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"admin-dashboard"); + + // /dashboard does NOT exist on public — 404 from axum + let (header, _) = dispatch(encode_wire("GET", "/dashboard", Some("public"))); + assert_eq!(header["status"].as_u64(), Some(404)); +} + +#[test] +fn public_app_routes_isolated_from_admin_app() { + let (header, body) = dispatch(encode_wire("GET", "/health", Some("public"))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"public-health"); + + // Admin doesn't have /health + let (header, _) = dispatch(encode_wire("GET", "/health", Some("admin"))); + assert_eq!(header["status"].as_u64(), Some(404)); +} + +#[test] +fn default_app_does_not_see_named_app_routes() { + // /dashboard exists on admin, but the default app doesn't have it + let (header, _) = dispatch(encode_wire("GET", "/dashboard", None)); + assert_eq!(header["status"].as_u64(), Some(404)); +} + +#[test] +fn unknown_app_name_returns_404() { + let (header, body) = dispatch(encode_wire("GET", "/anything", Some("nonexistent"))); + assert_eq!(header["status"].as_u64(), Some(404)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("no app registered with name 'nonexistent'"), + "explanation should name the missing app, got {msg}" + ); +} + +#[test] +fn invalid_app_name_with_special_chars_returns_400() { + let (header, body) = dispatch(encode_wire("GET", "/root", Some("bad name!"))); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("invalid app name"), + "expected 'invalid app name' explanation, got {msg}" + ); +} + +#[test] +fn whitespace_only_app_name_falls_back_to_default() { + // " " trims to empty → treated as default per resolve_app_router. + let (header, body) = dispatch(encode_wire("GET", "/root", Some(" "))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"default-root"); +} + +#[test] +fn register_app_named_first_wins_per_name() { + // After install_all_apps, "admin" router has /dashboard. A second + // register_app_named call with the same name must NOT replace the + // first registration. + install_all_apps(); + register_app_named("admin", || { + Router::new().route("/intruder", get(|| async { "should-not-be-reachable" })) + }); + + // Original /dashboard route still works + let (header, body) = dispatch(encode_wire("GET", "/dashboard", Some("admin"))); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"admin-dashboard"); + + // The second-registration route is NOT reachable + let (header, _) = dispatch(encode_wire("GET", "/intruder", Some("admin"))); + assert_eq!(header["status"].as_u64(), Some(404)); +} + +#[test] +fn register_app_named_with_invalid_name_is_silently_discarded() { + register_app_named("bad name!", || { + Router::new().route("/whatever", get(|| async { "should-not-register" })) + }); + // The invalid registration should not have created an app, + // so dispatching to "bad name!" still returns 400 (invalid name), + // not 200. + let (header, _) = dispatch(encode_wire("GET", "/whatever", Some("bad name!"))); + assert_eq!(header["status"].as_u64(), Some(400)); +} + +#[test] +fn headers_forwarded_to_correct_app() { + let mut header = serde_json::Map::new(); + header.insert("v".to_owned(), Value::from(1u8)); + header.insert("method".to_owned(), Value::String("GET".to_owned())); + header.insert("path".to_owned(), Value::String("/users".to_owned())); + header.insert("app".to_owned(), Value::String("admin".to_owned())); + let mut headers_obj = serde_json::Map::new(); + headers_obj.insert("x-custom".to_owned(), Value::String("hello".to_owned())); + header.insert("headers".to_owned(), Value::Object(headers_obj)); + let header_bytes = serde_json::to_vec(&Value::Object(header)).unwrap(); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!(body, b"admin-users"); + // Just confirm dispatch succeeded; the test handler doesn't echo + // headers — the multi_app integration concern is only that the + // request reached the correct app router. + let _ = HashMap::::new(); +} diff --git a/crates/vespera_inprocess/tests/register_app_idempotent.rs b/crates/vespera_inprocess/tests/register_app_idempotent.rs index 9b23dbd..4fc6e19 100644 --- a/crates/vespera_inprocess/tests/register_app_idempotent.rs +++ b/crates/vespera_inprocess/tests/register_app_idempotent.rs @@ -2,13 +2,46 @@ //! a second (or later) `register_app` call must be a no-op that //! preserves the originally registered router, without invoking the //! supplied factory closure a second time. +//! +//! Exercises the registered router via the binary wire API +//! ([`dispatch_from_bytes`]) — same code path the JNI bridge uses. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use axum::Router; use axum::routing::get; -use vespera_inprocess::{dispatch_from_json, register_app}; +use serde_json::Value; +use vespera_inprocess::{dispatch_from_bytes, register_app}; + +/// Encode a wire-format request for the given method + path with no +/// body and no headers. Mirrors what the Java side will send. +fn encode_wire(method: &str, path: &str) -> Vec { + let header = serde_json::json!({ + "v": 1, + "method": method, + "path": path, + }); + let header_bytes = serde_json::to_vec(&header).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire +} + +/// Decode a wire-format response: returns the header JSON as a value. +fn decode_wire_header(resp: &[u8]) -> Value { + assert!(resp.len() >= 4, "wire response too short ({})", resp.len()); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "wire header_len {header_len} overflows response ({} bytes)", + resp.len() + ); + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header is valid JSON") +} #[test] fn second_register_is_noop_first_wins() { @@ -27,9 +60,7 @@ fn second_register_is_noop_first_wins() { }); register_app(|| { - unreachable!( - "third register_app call must be a no-op without invoking the factory" - ); + unreachable!("third register_app call must be a no-op without invoking the factory"); }); assert_eq!( @@ -45,26 +76,21 @@ fn second_register_is_noop_first_wins() { .expect("build tokio runtime"); // First registration's route must be reachable. - let response = - dispatch_from_json(r#"{"method":"GET","path":"/from-first"}"#, &runtime); - let v: serde_json::Value = - serde_json::from_str(&response).expect("response is JSON"); + let resp = dispatch_from_bytes(encode_wire("GET", "/from-first"), &runtime); + let header = decode_wire_header(&resp); assert_eq!( - v["status"].as_u64().expect("status is integer"), + header["status"].as_u64().expect("status is integer"), 200, - "first registration's route must still be reachable after the no-op second register_app" + "first registration's route must still be reachable after the no-op second register_app: {header:#}" ); // Second registration's route must NOT be reachable — the second - // factory was never invoked so the router was never built, much less - // installed. - let response = - dispatch_from_json(r#"{"method":"GET","path":"/from-second"}"#, &runtime); - let v: serde_json::Value = - serde_json::from_str(&response).expect("response is JSON"); + // factory was never invoked so the router was never built. + let resp = dispatch_from_bytes(encode_wire("GET", "/from-second"), &runtime); + let header = decode_wire_header(&resp); assert_eq!( - v["status"].as_u64().expect("status is integer"), + header["status"].as_u64().expect("status is integer"), 404, - "second registration was a no-op — its route must not exist on the registered router" + "second registration was a no-op — its route must not exist on the registered router: {header:#}" ); } diff --git a/crates/vespera_inprocess/tests/wire_format.rs b/crates/vespera_inprocess/tests/wire_format.rs new file mode 100644 index 0000000..f41fd8b --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_format.rs @@ -0,0 +1,165 @@ +//! Negative-path tests for the binary wire decoder +//! ([`vespera_inprocess::dispatch_from_bytes`]). +//! +//! NOTE: this test binary does NOT call `register_app`, so the +//! happy-path "unknown app" check naturally produces a 500. All other +//! checks fail BEFORE the app lookup so the absence of a router is +//! orthogonal. + +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::dispatch_from_bytes; + +fn dispatch(wire: Vec) -> (Value, Vec) { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let resp = dispatch_from_bytes(wire, &runtime); + assert!(resp.len() >= 4, "wire response too short ({})", resp.len()); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len {header_len} overflows response" + ); + let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) + .expect("response header is valid JSON"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +#[test] +fn input_shorter_than_4_bytes_returns_400() { + let (header, body) = dispatch(vec![0x00, 0x01, 0x02]); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("too short"), + "expected 'too short' explanation, got {msg}" + ); +} + +#[test] +fn empty_input_returns_400() { + let (header, body) = dispatch(Vec::new()); + assert_eq!(header["status"].as_u64(), Some(400)); + assert!(!body.is_empty(), "error response must include a body"); +} + +#[test] +fn header_len_exceeding_input_returns_400() { + // header_len = 99999, but only 4 bytes total + let header_len: u32 = 99_999; + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + // No JSON, no body — header_len overflows. + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("exceeds"), + "expected 'exceeds' explanation, got {msg}" + ); +} + +#[test] +fn header_json_invalid_returns_400() { + let bad_json = b"this is not json at all"; + let header_len = u32::try_from(bad_json.len()).unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(bad_json); + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("JSON parse"), + "expected JSON parse error, got {msg}" + ); +} + +#[test] +fn wire_version_missing_returns_400_version_mismatch() { + // No "v" field -> serde default of 0, which != WIRE_VERSION (1). + let header_json = br#"{"method":"GET","path":"/ping"}"#; + let header_len = u32::try_from(header_json.len()).unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(header_json); + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("wire version"), + "expected 'wire version' explanation, got {msg}" + ); +} + +#[test] +fn wire_version_wrong_value_returns_400() { + let header_json = br#"{"v":42,"method":"GET","path":"/ping"}"#; + let header_len = u32::try_from(header_json.len()).unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(header_json); + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("42"), + "error must mention the received version, got {msg}" + ); +} + +#[test] +fn no_app_registered_returns_404_with_explanatory_body() { + // Well-formed wire request, but no app has been registered in + // THIS test binary (we deliberately never call register_app). + // Multi-app routing treats an unregistered name (including the + // default "_default") as a 404 — same status HTTP uses for an + // unknown route — rather than 500. + let header_json = br#"{"v":1,"method":"GET","path":"/ping"}"#; + let header_len = u32::try_from(header_json.len()).unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(header_json); + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(404)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("no app registered"), + "expected 'no app registered' explanation, got {msg}" + ); + assert!( + msg.contains("_default"), + "explanation should name the default app, got {msg}" + ); +} + +#[test] +fn invalid_app_name_returns_400() { + // Wire header carries "app" with invalid characters → 400, not 404 + let header_json = br#"{"v":1,"method":"GET","path":"/ping","app":"bad name!"}"#; + let header_len = u32::try_from(header_json.len()).unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(header_json); + let (header, body) = dispatch(wire); + assert_eq!(header["status"].as_u64(), Some(400)); + let msg = String::from_utf8_lossy(&body); + assert!( + msg.contains("invalid app name"), + "expected 'invalid app name' explanation, got {msg}" + ); +} + +#[test] +fn response_content_type_is_text_plain_for_errors() { + let (header, _body) = dispatch(vec![0u8; 3]); // too short + assert_eq!(header["status"].as_u64(), Some(400)); + let ct = header["headers"]["content-type"] + .as_str() + .expect("error response must have content-type"); + assert!(ct.starts_with("text/plain"), "got {ct}"); +} diff --git a/crates/vespera_jni/src/lib.rs b/crates/vespera_jni/src/lib.rs index ae7b024..b456804 100644 --- a/crates/vespera_jni/src/lib.rs +++ b/crates/vespera_jni/src/lib.rs @@ -17,11 +17,18 @@ pub use jni; pub use vespera_inprocess; -/// Generate the `JNI_OnLoad` export that registers your app. +/// Generate the `JNI_OnLoad` export that registers a single (default) +/// app. Backward-compatible sugar for the single-app case; new code +/// targeting multiple apps should use [`jni_apps!`] directly. /// /// ```ignore /// vespera::jni_app!(create_app); /// ``` +/// +/// Expands to `register_app(factory)` inside the generated +/// `JNI_OnLoad`. The resulting router is reachable from Java +/// without an `X-Vespera-App` header (or with the header set to +/// `"_default"`). #[macro_export] macro_rules! jni_app { ($factory:expr) => { @@ -36,6 +43,55 @@ macro_rules! jni_app { }; } +/// Generate the `JNI_OnLoad` export that registers **multiple named +/// apps** in a single declaration. This is the primary multi-app +/// entry point — exactly one `JNI_OnLoad` per cdylib is generated, +/// regardless of how many apps you register. +/// +/// ```ignore +/// vespera::jni_apps! { +/// "admin" => admin_app, +/// "public" => public_app, +/// } +/// ``` +/// +/// Each `name` must be a string literal matching the validation rules +/// in [`register_app_named`] (non-empty, ≤ 64 bytes, alphanumeric + +/// `_` `-`). Each `factory` is an expression evaluating to a +/// `Fn() -> Router + Send + Sync + 'static` (typically a `pub fn` +/// path). +/// +/// From the Java side, the request's `X-Vespera-App` header +/// (configurable) selects which app receives the dispatch. Requests +/// without the header are routed to the default app (registered via +/// [`jni_app!`] or `register_app`); requests naming an unregistered +/// app receive a 404 wire response. +/// +/// # Composition +/// +/// JNI requires exactly one `JNI_OnLoad` per cdylib. Use `jni_apps!` +/// (or `jni_app!`) **once** in the cdylib's root module; assemble +/// factories from submodules into that single invocation. Using +/// `jni_app!` and `jni_apps!` together — or `jni_apps!` more than +/// once — will produce a duplicate-symbol link error. +/// +/// [`register_app_named`]: vespera_inprocess::register_app_named +#[macro_export] +macro_rules! jni_apps { + ( $( $name:literal => $factory:expr ),+ $(,)? ) => { + #[unsafe(no_mangle)] + pub extern "system" fn JNI_OnLoad( + _vm: $crate::jni::JavaVM, + _: *mut ::std::ffi::c_void, + ) -> $crate::jni::sys::jint { + $( + $crate::vespera_inprocess::register_app_named($name, $factory); + )+ + $crate::jni::sys::JNI_VERSION_1_8 + } + }; +} + // Everything below requires a JVM — excluded from coverage. #[cfg(not(tarpaulin_include))] mod jni_impl { @@ -43,8 +99,9 @@ mod jni_impl { use jni::EnvUnowned; use jni::errors::ThrowRuntimeExAndDefault; - use jni::objects::{JClass, JObject, JString}; - use jni::sys::jstring; + use jni::objects::{Global, JByteArray, JClass, JObject, JValue}; + use jni::sys::jbyteArray; + use jni::{jni_sig, jni_str}; /// Multi-threaded Tokio runtime shared across all JNI calls. pub static RUNTIME: LazyLock = LazyLock::new(|| { @@ -54,28 +111,552 @@ mod jni_impl { .expect("failed to create Tokio runtime") }); - /// `com.devfive.vespera.bridge.VesperaBridge.dispatch(String) -> String` + /// Per-chunk buffer size for streaming dispatches (16 KiB — large + /// enough to amortise JNI call overhead, small enough to keep + /// memory bounded for multi-GB streams). + const STREAMING_CHUNK_SIZE: usize = 16 * 1024; + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]` + /// + /// **Synchronous** binary wire-format JNI entry point. Blocks the + /// calling thread until the Rust dispatch completes. Wraps the + /// entire pipeline in `catch_unwind` so a panic anywhere produces + /// a valid wire-format `500` response with a plain-text body — + /// JVM never sees an unwinding stack across the FFI boundary. #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatch<'local>( + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchBytes<'local>( mut unowned_env: EnvUnowned<'local>, _class: JClass<'local>, - request_json: JString<'local>, - ) -> jstring { + request_bytes: JByteArray<'local>, + ) -> jbyteArray { unowned_env .with_env(|env| -> jni::errors::Result> { - let Ok(input) = request_json.try_to_string(env) else { - let err = vespera_inprocess::serialize_error("invalid request envelope string"); - return Ok(env.new_string(err)?.into()); + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); }; - let json = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - vespera_inprocess::dispatch_from_json(&input, &RUNTIME) + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + vespera_inprocess::dispatch_from_bytes(input, &RUNTIME) })) - .unwrap_or_else(|_| vespera_inprocess::serialize_error("panic in Rust engine")); + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - Ok(env.new_string(json)?.into()) + Ok(env.byte_array_from_slice(&response)?.into()) }) .resolve::() .into_raw() } + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchAsync(CompletableFuture, byte[]) -> void` + /// + /// **Asynchronous** binary wire-format JNI entry point. Returns + /// immediately after spawning the dispatch on the shared Tokio + /// runtime. Completes the supplied `CompletableFuture` + /// from a runtime worker thread once the response is ready. + /// + /// Contract (always-complete): + /// - **success** → `future.complete(responseBytes)` + /// - **JNI conversion failure** → `future.complete(error_wire(400, ...))` + /// - **Rust panic / handler crash** → `future.complete(error_wire(500, "panic in Rust engine"))` + /// The future is always completed with a valid wire response — + /// it is never left dangling, even on internal errors. + /// + /// Cancellation: Java's `future.cancel(true)` does NOT abort the + /// in-flight Rust task in this iteration (defer to follow-up). + /// Java callers may still observe cancellation via `future.isCancelled()`. + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchAsync<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + future_obj: JObject<'local>, + request_bytes: JByteArray<'local>, + ) { + // Best-effort: any error inside with_env aborts the dispatch + // (future will dangle on the Java side — only happens if we + // can't even promote the future to a GlobalRef, which would + // mean the JVM is already in trouble). + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + // 1. Promote CompletableFuture to Global so it survives + // across the tokio task boundary. + let future_global: Global> = env.new_global_ref(&future_obj)?; + + // 2. Try to convert the input byte array. On failure, + // complete the future synchronously with the error wire + // and return early — no async work needed. + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + let _ = complete_future(env, &future_global, &err); + return Ok(()); + }; + + // 3. Snapshot the JavaVM (Send + Sync) so we can re-attach + // the tokio worker thread once the dispatch completes. + let jvm = env.get_java_vm()?; + + // 4. Fire-and-forget on the runtime. An inner tokio::spawn + // converts any panic in dispatch_from_bytes_async into + // a JoinError, guaranteeing always-complete semantics. + RUNTIME.spawn(async move { + let response = tokio::spawn(vespera_inprocess::dispatch_from_bytes_async(input)) + .await + .unwrap_or_else(|_| { + vespera_inprocess::error_wire(500, "panic in Rust engine") + }); + + // Re-attach to JVM on this worker thread; subsequent + // dispatches on the same thread will hit the TLS fast + // path (cheap). + let _ = jvm.attach_current_thread(|env| -> jni::errors::Result<()> { + complete_future(env, &future_global, &response) + }); + }); + + Ok(()) + }); + } + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreaming(byte[], OutputStream) -> byte[]` + /// + /// **Streaming** JNI entry point. Drives the dispatch + /// synchronously like [`Java_...dispatchBytes`], but emits the + /// response body chunk-by-chunk by calling `outputStream.write(byte[])` + /// for each chunk axum produces — no full-body materialisation on + /// either the Rust or JVM side. + /// + /// Returns the wire-format **header only** (`[u32 BE header_len | + /// header JSON]`) — the body is delivered through the + /// `OutputStream` argument while the dispatch is in flight. + /// Callers (e.g. Spring `StreamingResponseBody`) read the header + /// first to commit the HTTP status + response headers, then + /// continue serving the streamed body bytes. + /// + /// Failure modes mirror [`Java_...dispatchBytes`]: malformed wire, + /// version mismatch, no app registered, or Rust panic produce a + /// regular `error_wire(...)` response (header + small body) and + /// the `OutputStream` is **not** written to. + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + output_stream: JObject<'local>, + ) -> jbyteArray { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + + // Promote the OutputStream to Global so we can call + // .write() from a different attached thread inside + // the streaming callback. + let stream_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( + input, + |chunk: &[u8]| { + // Per-chunk: attach (cheap on subsequent + // calls — TLS fast path) + push a local + // frame to keep the local-ref table bounded + // even for streams with thousands of chunks. + let _ = jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>( + 8, + |env| { + let arr = env.byte_array_from_slice(chunk)?; + let arr_obj: JObject = arr.into(); + env.call_method( + &stream_global, + jni_str!("write"), + jni_sig!("([B)V"), + &[JValue::Object(&arr_obj)], + )?; + // Any IOException thrown by write() is left + // pending on the env; clear it so subsequent + // chunks on the same thread aren't poisoned. + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }, + ) + }, + ); + }, + )) + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + Ok(env.byte_array_from_slice(&header_bytes)?.into()) + }) + .resolve::() + .into_raw() + } + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreaming(byte[], InputStream, OutputStream) -> byte[]` + /// + /// **Bidirectional streaming** JNI entry point. Reads the request + /// body chunk-by-chunk from `inputStream.read(byte[])` and emits + /// response body chunks via `outputStream.write(byte[])` — neither + /// side ever materialises the full body in memory, so 1 GiB + /// uploads with 1 GiB downloads run in O(chunk_size) RAM. + /// + /// Returns the wire-format **header only** (`[u32 BE header_len | + /// header JSON]`); the response body was delivered through + /// `outputStream`. + /// + /// Wire envelope contract: + /// - `headerBytes` is a wire-format request **without a body** + /// (just the 4-byte length prefix + JSON header). Send the + /// request body via `inputStream`, not embedded in this buffer. + /// - `inputStream.read(byte[])` semantics: returns `-1` on EOF, + /// `0` for an empty read (will be retried), or `>0` for the + /// number of bytes read into the supplied buffer. + /// + /// Failure modes mirror [`Java_...dispatchStreaming`]: malformed + /// wire / unknown version / no app / Rust panic produce a normal + /// `error_wire(...)` response in the returned bytes and neither + /// stream is touched. + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes: JByteArray<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, + ) -> jbyteArray { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let Ok(header_input) = env.convert_byte_array(&header_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid header byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + + let input_global: Global> = env.new_global_ref(&input_stream)?; + let output_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // Closures capture clones of the JavaVM and Globals; + // both types are Send+Sync. + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let push_jvm = jvm; + let push_global = output_global; + + let header_response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( + header_input, + // Pull request body chunks from Java InputStream. + // Runs on a tokio blocking thread (spawn_blocking + // inside dispatch_bidirectional_streaming). + move || -> Option> { + let result: jni::errors::Result>> = pull_jvm + .attach_current_thread(|env| { + env.with_local_frame::<_, _, jni::errors::Error>( + 8, + |env| { + let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; + let n = env + .call_method( + &pull_global, + jni_str!("read"), + jni_sig!("([B)I"), + &[JValue::Object(arr.as_ref())], + )? + .i()?; + if env.exception_check() { + env.exception_clear(); + } + if n <= 0 { + return Ok(None); + } + let mut data = env.convert_byte_array(&arr)?; + data.truncate(usize::try_from(n).unwrap_or(0)); + Ok(Some(data)) + }, + ) + }); + result.ok().flatten() + }, + // Push response body chunks to Java OutputStream. + // Runs on the tokio worker driving the dispatch. + |chunk: &[u8]| { + let _ = push_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(chunk)?; + let arr_obj: JObject = arr.into(); + env.call_method( + &push_global, + jni_str!("write"), + jni_sig!("([B)V"), + &[JValue::Object(&arr_obj)], + )?; + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }) + }, + ); + }, + )) + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + Ok(env.byte_array_from_slice(&header_response)?.into()) + }) + .resolve::() + .into_raw() + } + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreamingWithHeader(byte[], Consumer, OutputStream) -> void` + /// + /// Same as [`Java_...dispatchStreaming`] but emits the wire-format + /// response header via `headerConsumer.accept(byte[])` **before** + /// the first body byte reaches `outputStream`. This lets + /// Spring-style `HttpServletResponse` controllers commit status + /// and headers while the response is still uncommitted. + /// + /// `headerConsumer` is invoked exactly once on every code path + /// (success or error); the bytes are a normal wire-format header + /// (length-prefixed JSON). On error `outputStream` is not + /// touched. + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + header_consumer: JObject<'local>, + output_stream: JObject<'local>, + ) { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); + return Ok(()); + }; + + let header_global: Global> = env.new_global_ref(&header_consumer)?; + let stream_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // Panic safety: catch_unwind absorbs Rust panics so the + // JVM never sees an unwinding stack across the FFI + // boundary. If the panic happens AFTER the header + // callback fires (the common case — most panics are in + // axum handlers), Spring's response is already partially + // committed; we have no way to recover that. If the + // panic happens BEFORE the header callback fires (very + // rare — e.g. wire parse), the Java side will see a + // dangling controller; document that follow-up callers + // should set a timeout. + let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let header_for_cb = header_global; + let stream_for_cb = stream_global; + let jvm_for_cb = jvm; + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( + input, + |header_bytes: &[u8]| { + let _ = jvm_for_cb.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ); + }, + |chunk: &[u8]| { + let _ = jvm_for_cb.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + write_chunk_to_stream(env, &stream_for_cb, chunk) + }) + }, + ); + }, + )); + })); + + Ok(()) + }); + } + + /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) -> void` + /// + /// Bidirectional streaming with the same header-callback contract + /// as [`Java_...dispatchStreamingWithHeader`]. Request body + /// pulled from `inputStream`, response header emitted via + /// `headerConsumer.accept(byte[])` once axum produces status + + /// headers, then response body chunks streamed to `outputStream`. + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreamingWithHeader< + 'local, + >( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes_in: JByteArray<'local>, + header_consumer: JObject<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, + ) { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + let Ok(header_input) = env.convert_byte_array(&header_bytes_in) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid header byte array (JNI conversion failed)", + ); + let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); + return Ok(()); + }; + + let header_global: Global> = env.new_global_ref(&header_consumer)?; + let input_global: Global> = env.new_global_ref(&input_stream)?; + let output_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let push_jvm = jvm.clone(); + let push_global = output_global; + let header_jvm = jvm; + let header_for_cb = header_global; + + // See dispatchStreamingWithHeader: panic absorbed silently, + // recovery semantics depend on which side of the header + // callback the panic landed. + let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming_with_header( + header_input, + move || -> Option> { + let result: jni::errors::Result>> = pull_jvm + .attach_current_thread(|env| { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; + let n = env + .call_method( + &pull_global, + jni_str!("read"), + jni_sig!("([B)I"), + &[JValue::Object(arr.as_ref())], + )? + .i()?; + if env.exception_check() { + env.exception_clear(); + } + if n <= 0 { + return Ok(None); + } + let mut data = env.convert_byte_array(&arr)?; + data.truncate(usize::try_from(n).unwrap_or(0)); + Ok(Some(data)) + }) + }); + result.ok().flatten() + }, + |chunk: &[u8]| { + let _ = push_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + write_chunk_to_stream(env, &push_global, chunk) + }) + }, + ); + }, + |header_bytes: &[u8]| { + let _ = header_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ); + }, + )); + })); + + Ok(()) + }); + } + + fn call_header_consumer( + env: &mut jni::Env<'_>, + consumer: &Global>, + header_bytes: &[u8], + ) -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(header_bytes)?; + let arr_obj: JObject = arr.into(); + env.call_method( + consumer, + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(&arr_obj)], + )?; + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }) + } + + fn write_chunk_to_stream( + env: &mut jni::Env<'_>, + stream: &Global>, + chunk: &[u8], + ) -> jni::errors::Result<()> { + let arr = env.byte_array_from_slice(chunk)?; + let arr_obj: JObject = arr.into(); + env.call_method( + stream, + jni_str!("write"), + jni_sig!("([B)V"), + &[JValue::Object(&arr_obj)], + )?; + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + } + + /// Call `CompletableFuture.complete(byte[])` and clear any pending + /// JNI exception so the worker thread is left clean for subsequent + /// dispatches. + fn complete_future( + env: &mut jni::Env<'_>, + future: &Global>, + bytes: &[u8], + ) -> jni::errors::Result<()> { + let arr = env.byte_array_from_slice(bytes)?; + let arr_obj: JObject = arr.into(); + env.call_method( + future, + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + &[JValue::Object(&arr_obj)], + )?; + // Always clear any leftover exception (e.g. if Java's + // complete() threw via a buggy whenComplete handler): we MUST + // NOT leave the attached thread in a faulted state because + // subsequent JNI calls will misbehave silently. + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + } } diff --git a/crates/vespera_macro/src/garde_emit.rs b/crates/vespera_macro/src/garde_emit.rs index 054d05a..4022ef8 100644 --- a/crates/vespera_macro/src/garde_emit.rs +++ b/crates/vespera_macro/src/garde_emit.rs @@ -145,7 +145,8 @@ fn emit_field_block( let field_name_str = field_ident.to_string(); let numeric_kind = rust_numeric_kind(peel_option(field_ty).unwrap_or(field_ty)); let rule_blocks = emit_rule_blocks(c, &field_name_str, numeric_kind.as_deref()); - if rule_blocks.is_empty() { + let dive_block = emit_dive_block(c); + if rule_blocks.is_empty() && dive_block.is_empty() { return None; } @@ -159,6 +160,7 @@ fn emit_field_block( ); if let ::std::option::Option::Some(__garde_binding) = #field_ident { #rule_blocks + #dive_block } } } @@ -170,6 +172,7 @@ fn emit_field_block( ); let __garde_binding = &*#field_ident; #rule_blocks + #dive_block } } }; @@ -177,6 +180,36 @@ fn emit_field_block( Some(block) } +/// Emit the `garde::Validate::validate_into` call for fields annotated +/// with `#[schema(dive)]`. +/// +/// Garde's runtime `Validate` impls for `Option`, `Vec`, +/// `HashMap`, and `BTreeMap` automatically unwrap / +/// iterate, so the emitted call is identical regardless of container — +/// it dispatches to the appropriate impl by trait resolution and the +/// runtime pushes the right path components (`name`, `tags[0]`, +/// `m["key"]`, …). +/// +/// For Option-typed fields we already emit an outer `if let Some(...)` +/// so `__garde_binding` is the unwrapped inner value here; the +/// `Option`-aware behaviour is therefore intentionally bypassed for +/// uniformity with the other rule blocks in this file. +#[cfg(feature = "validation")] +fn emit_dive_block(c: &SchemaConstraints) -> TokenStream { + if c.dive == Some(true) { + quote! { + ::vespera::__validation::garde::Validate::validate_into( + &*__garde_binding, + __garde_user_ctx, + &mut __garde_path, + __garde_report, + ); + } + } else { + TokenStream::new() + } +} + #[cfg(feature = "validation")] #[allow(clippy::too_many_lines)] // exhaustive rule-to-emit dispatcher fn emit_rule_blocks( @@ -626,4 +659,89 @@ mod tests { }; assert!(emit_to_string(s).is_empty()); } + + // ── nested validation (`#[schema(dive)]`) emission ────────────── + + #[test] + fn dive_on_plain_field_emits_validate_into_call() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub address: Address, + } + }; + let out = emit_to_string(s); + assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for Order")); + assert!(out.contains("Validate :: validate_into")); + assert!(out.contains("\"address\"")); + } + + #[test] + fn dive_on_option_wraps_in_if_let_some() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub address: Option
, + } + }; + let out = emit_to_string(s); + assert!(out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("Validate :: validate_into")); + } + + #[test] + fn dive_on_vec_emits_single_validate_into_call() { + // garde's runtime `Vec: Validate` impl iterates and pushes + // `[idx]` path components automatically — the macro only emits + // one `validate_into` call regardless of container kind. + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub items: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("Validate :: validate_into")); + // `validate_into` appears twice: once as the outer fn declaration + // (`fn validate_into(...)`) and once as the inner trait dispatch + // (`Validate :: validate_into(...)`). Anything more would mean + // the macro is iterating itself, which is what we explicitly + // delegate to garde's runtime `Vec: Validate` impl. + assert_eq!( + out.matches("validate_into").count(), + 2, + "expected outer fn + one inner trait call; iteration is garde-runtime, \ + so the macro must NOT emit a `for` loop" + ); + // `for` keyword appears in `impl ... for Order` — count only + // tokens that look like loop iteration (`for in `). + let loop_count = out.matches("in __garde_binding").count(); + assert_eq!(loop_count, 0, "macro must not emit explicit iteration"); + } + + #[test] + fn dive_combined_with_length_emits_both_rules() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(min_items = 1, max_items = 10, dive)] + pub items: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: simple :: apply")); + assert!(out.contains("Validate :: validate_into")); + } + + #[test] + fn dive_false_disables_emission() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive = false)] + pub address: Address, + } + }; + // `dive = false` is the same as no annotation — no rule + // produced means no `impl Validate` emitted. + assert!(emit_to_string(s).is_empty()); + } } diff --git a/crates/vespera_macro/src/parser/schema/schema_attrs.rs b/crates/vespera_macro/src/parser/schema/schema_attrs.rs index d745df4..9aa807b 100644 --- a/crates/vespera_macro/src/parser/schema/schema_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/schema_attrs.rs @@ -67,6 +67,19 @@ pub struct SchemaConstraints { pub example: Option, pub read_only: Option, pub write_only: Option, + + // ── nested validation ─────────────────────────────────────────── + /// When `Some(true)`, the field is recursively validated by + /// invoking `garde::Validate::validate_into` on its value. The + /// path of any reported error is prefixed with the field name + /// (e.g. `"address.city"`), and garde's runtime impls for + /// `Option`, `Vec`, `HashMap`, `BTreeMap` automatically handle + /// unwrapping / iteration. This corresponds to + /// `#[garde(dive)]` semantics and is opt-in to avoid accidental + /// trait-bound failures on field types that don't implement + /// `garde::Validate` (e.g. `chrono::DateTime`, `uuid::Uuid`, + /// most third-party newtypes). + pub dive: Option, } impl SchemaConstraints { @@ -88,6 +101,7 @@ impl SchemaConstraints { && self.example.is_none() && self.read_only.is_none() && self.write_only.is_none() + && self.dive.is_none() } /// `true` when at least one constraint produces a `garde` runtime rule @@ -109,6 +123,7 @@ impl SchemaConstraints { self.format.as_deref(), Some("email" | "uri" | "url" | "ipv4" | "ipv6" | "ip") ) + || self.dive == Some(true) } } @@ -163,6 +178,13 @@ pub fn extract_schema_constraints(attrs: &[Attribute]) -> SchemaConstraints { out.read_only = Some(parse_bool_or_default_true(&meta)?); } else if meta.path.is_ident("write_only") { out.write_only = Some(parse_bool_or_default_true(&meta)?); + } else if meta.path.is_ident("dive") { + // Opt-in recursive validation. Mirrors `#[garde(dive)]`: + // emits a `Validate::validate_into` call so nested + // structs, `Vec`, `Option`, + // `HashMap<_, Validate>` are validated transparently + // and errors carry dotted paths like "address.city". + out.dive = Some(parse_bool_or_default_true(&meta)?); } else { // Unknown key — could be a struct-level key like `name`, // `ref`, `nullable`, `default` that lives on the same @@ -424,6 +446,31 @@ mod tests { assert_eq!(c.write_only, Some(false)); } + #[test] + fn dive_bare_keyword_defaults_to_true() { + let c = parse(&[parse_quote!(#[schema(dive)])]); + assert_eq!(c.dive, Some(true)); + assert!(c.has_runtime_rule(), "dive should count as a runtime rule"); + } + + #[test] + fn dive_explicit_false_disables_runtime_rule() { + let c = parse(&[parse_quote!(#[schema(dive = false)])]); + assert_eq!(c.dive, Some(false)); + assert!( + !c.has_runtime_rule(), + "dive = false must not register a runtime rule" + ); + } + + #[test] + fn dive_combines_with_other_constraints() { + let c = parse(&[parse_quote!(#[schema(min_items = 1, max_items = 10, dive)])]); + assert_eq!(c.min_items, Some(1)); + assert_eq!(c.max_items, Some(10)); + assert_eq!(c.dive, Some(true)); + } + #[test] fn mixed_struct_and_field_keys_in_one_attr_are_partitioned_correctly() { // A user might write a single `#[schema(name = "...", min_length = 3)]`. diff --git a/crates/vespera_macro/src/route_impl.rs b/crates/vespera_macro/src/route_impl.rs index 9ea9deb..7b334ea 100644 --- a/crates/vespera_macro/src/route_impl.rs +++ b/crates/vespera_macro/src/route_impl.rs @@ -38,28 +38,34 @@ use crate::args; /// Metadata stored by `#[route]` for later consumption by `vespera!()`. /// /// Each invocation of `#[route]` pushes one entry into [`ROUTE_STORAGE`]. -/// The `vespera!()` macro reads this storage to supplement file-based route discovery. +/// The `vespera!()` macro reads this storage to supplement file-based +/// route discovery — when `file_path` is populated, the collector can +/// build route metadata directly from this struct without re-parsing +/// the source file with `syn::parse_file()`. #[derive(Debug, Clone)] pub struct StoredRouteInfo { - /// Function name (e.g., `"get_user"`) + /// Function name (e.g., `"get_user"`). pub fn_name: String, - /// HTTP method — stored for Phase 3 (skip file re-parsing) - #[allow(dead_code)] + /// HTTP method (e.g., `"get"`, `"post"`). Used by the collector's + /// fast path ([`crate::collector`]) to populate `RouteMetadata.method` + /// without re-parsing the source file. pub method: Option, - /// Custom path from `path = "/{id}"` — stored for Phase 3 - #[allow(dead_code)] + /// Custom path from `path = "/{id}"`. Used by the collector to + /// derive the full route URL when present. pub custom_path: Option, - /// Additional error status codes from `error_status = [400, 404]` + /// Additional error status codes from `error_status = [400, 404]`. pub error_status: Option>, - /// Tags for `OpenAPI` grouping from `tags = ["users"]` + /// Tags for `OpenAPI` grouping from `tags = ["users"]`. pub tags: Option>, - /// Description from `description = "Get user by ID"` + /// Description from `description = "Get user by ID"`. pub description: Option, - /// Source file path from `Span::call_site().local_file()` (requires Rust 1.88+) + /// Source file path from `Span::call_site().local_file()` (requires Rust 1.88+). /// `None` on older Rust — collector falls back to full file parsing. pub file_path: Option, - /// Full function item as string for later AST re-parsing (Phase 3) - #[allow(dead_code)] + /// Full function item as a string. Re-parsed via `syn::parse_str()` + /// by both [`crate::collector`] and [`crate::openapi_generator`] so + /// the source file does not need to be opened from disk for routes + /// already known via `ROUTE_STORAGE`. pub fn_item_str: String, } diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 3755e45..358c8ca 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -1070,6 +1070,10 @@ async fn test_openapi_contains_typed_form_schemas() { // for both text fields and file (NamedTempFile) fields. /// Test struct with intentionally small limits for limit enforcement testing. +/// The `data` and `file` fields are intentionally not read in the handler — +/// the test exercises the multipart parser's limit-rejection path, so the +/// fields must exist (so the derive macro registers them with their limits) +/// but the handler never touches their values. #[derive(Debug, Multipart)] #[allow(dead_code)] struct FormDataLimitTestRequest { @@ -1215,7 +1219,6 @@ fn default_greeting() -> String { /// Test struct with serde rename and default attributes. #[derive(Debug, Multipart)] #[serde(rename_all = "camelCase")] -#[allow(dead_code)] struct SerdeAttrTestRequest { /// Uses camelCase rename from struct-level rename_all. pub user_name: String, @@ -1244,7 +1247,6 @@ async fn serde_attr_handler( /// Test struct with struct-level `#[serde(default)]`. #[derive(Debug, Multipart)] #[serde(default)] -#[allow(dead_code)] struct StructDefaultTestRequest { pub name: String, pub count: i32, @@ -1396,7 +1398,6 @@ async fn test_serde_default_overridden_when_provided() { /// Test struct with Vec field for repeated multipart fields. #[derive(Debug, Multipart)] -#[allow(dead_code)] struct VecFieldTestRequest { pub name: String, pub tags: Vec, @@ -1414,7 +1415,6 @@ async fn vec_field_handler( /// Test struct with strict mode enabled. #[derive(Debug, Multipart)] #[try_from_multipart(strict)] -#[allow(dead_code)] struct StrictModeTestRequest { pub name: String, pub age: i32, @@ -1431,7 +1431,6 @@ async fn strict_mode_handler( /// Test struct with form_data(field_name) override. #[derive(Debug, Multipart)] -#[allow(dead_code)] struct FieldNameOverrideTestRequest { pub name: String, #[form_data(field_name = "custom_field")] @@ -1449,7 +1448,6 @@ async fn field_name_override_handler( /// Test struct with form_data(default) attribute. #[derive(Debug, Multipart)] -#[allow(dead_code)] struct FormDataDefaultTestRequest { pub name: String, #[form_data(default)] @@ -1467,7 +1465,6 @@ async fn form_data_default_handler( /// Test struct with numeric and char fields for type parsing coverage. #[derive(Debug, Multipart)] -#[allow(dead_code)] struct NumericCharTestRequest { pub name: String, pub count: i32, diff --git a/examples/rust-jni-demo/README.md b/examples/rust-jni-demo/README.md index 12b287f..b4709e9 100644 --- a/examples/rust-jni-demo/README.md +++ b/examples/rust-jni-demo/README.md @@ -158,10 +158,30 @@ public class DemoApplication { 1. `vespera::jni_app!` generates `JNI_OnLoad` → calls `vespera::inprocess::register_app(create_app)` 2. Java calls `VesperaBridge.init("rust_jni_demo")` → loads cdylib → triggers `JNI_OnLoad` -3. `VesperaProxyController` catches all HTTP requests → calls `VesperaBridge.dispatch(json)` -4. JNI symbol delegates to `vespera::inprocess::dispatch_from_json()` -5. `dispatch_from_json` gets the registered factory → builds Router → `router.oneshot(request)` -6. No TCP between Java and Rust +3. `VesperaProxyController` catches all HTTP requests → encodes them into the **binary wire format** via `VesperaBridge.encodeRequest(...)` → calls `VesperaBridge.dispatchBytes(byte[])` +4. JNI symbol delegates to `vespera::inprocess::dispatch_from_bytes()` +5. `dispatch_from_bytes` parses the wire header, looks up the cached `Router`, and runs `router.oneshot(request)` with the raw body bytes +6. Response wire bytes flow back the same way; `VesperaBridge.decodeResponse(byte[])` produces a `DecodedResponse` and the controller returns either `ResponseEntity` (text-like Content-Type) or `ResponseEntity` (binary) +7. No TCP between Java and Rust; **no base64** — multipart uploads, PDFs, images travel as raw bytes + +#### Wire format + +``` +[u32 BE header_len][UTF-8 JSON header][raw body bytes] +``` + +Header JSON (request and response): + +```jsonc +// request +{ "v": 1, "method": "POST", "path": "/upload", + "query": "user=alice", "headers": {"content-type": "..."} } + +// response +{ "v": 1, "status": 200, "headers": {...}, "metadata": {"version": "0.1.51"} } +``` + +All failure paths (malformed wire, Rust panic, no app registered) return a length-prefixed wire response with `status: 4xx/5xx` and a plain-text body, so the Java decoder never has to special-case errors. ### Maven/Gradle dependency diff --git a/examples/rust-jni-demo/java/demo-app/build.gradle.kts b/examples/rust-jni-demo/java/demo-app/build.gradle.kts index 8771739..12aeda2 100644 --- a/examples/rust-jni-demo/java/demo-app/build.gradle.kts +++ b/examples/rust-jni-demo/java/demo-app/build.gradle.kts @@ -2,71 +2,34 @@ plugins { java id("org.springframework.boot") version "3.2.5" id("io.spring.dependency-management") version "1.1.4" + // ─────────────────────────────────────────────────────────────────── + // Vespera bridge plugin — auto-wires: + // - bundleNativeLib task (cdylib → resources/native/-/) + // - processResources dependency on bundleNativeLib + // - kr.devfive:vespera-bridge implementation dep + // + // Before this plugin: 22 lines of boilerplate (Copy task, OS/arch + // detection helpers, library-name mapping, processResources wiring). + // After: the 5-line `vespera { ... }` block below. + // ─────────────────────────────────────────────────────────────────── + id("kr.devfive.vespera-bridge") version "0.0.15" } group = "kr.go.demo" version = "0.1.0" -// ── Rust native library location ───────────────────────────────────── -// Point this to your cargo build output. Override with: -// ./gradlew bootJar -PrustTarget=x86_64-unknown-linux-gnu -val repoRoot = rootProject.projectDir.resolve("../../..") -val rustRelease = repoRoot.resolve("target/release") +vespera { + crateName.set("rust_jni_demo") + cargoRoot.set(rootProject.layout.projectDirectory.dir("../../..")) + bridgeVersion.set("0.0.15") +} dependencies { - implementation(files("${repoRoot}/libs/vespera-bridge/build/libs/vespera-bridge-0.1.0.jar")) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("org.springframework.boot:spring-boot-starter-test") } -// ── Bundle native library into JAR ─────────────────────────────────── -// Copies the Rust .dll/.so/.dylib into src/main/resources/native/{os}-{arch}/ -// so VesperaBridge.loadBundled() can extract it at runtime. -tasks.register("bundleNativeLib") { - val os = detectOs() - val arch = detectArch() - val libName = mapLibraryName(os, "rust_jni_demo") - - from(rustRelease.resolve(libName)) - into(layout.buildDirectory.dir("resources/main/native/${os}-${arch}")) - - doFirst { - val src = rustRelease.resolve(libName) - require(src.exists()) { - "Native library not found: $src\nRun: cargo build -p rust-jni-demo --release" - } - } -} - -tasks.named("processResources") { - dependsOn("bundleNativeLib") -} - tasks.test { useJUnitPlatform() } - -// ── Platform detection (mirrors VesperaBridge.java) ────────────────── -fun detectOs(): String { - val os = System.getProperty("os.name", "").lowercase() - return when { - "win" in os -> "windows" - "mac" in os || "darwin" in os -> "macos" - else -> "linux" - } -} - -fun detectArch(): String { - val arch = System.getProperty("os.arch", "").lowercase() - return when { - "amd64" in arch || "x86_64" in arch -> "x86_64" - "aarch64" in arch || "arm64" in arch -> "aarch64" - else -> arch - } -} - -fun mapLibraryName(os: String, name: String): String = when (os) { - "windows" -> "$name.dll" - "macos" -> "lib$name.dylib" - else -> "lib$name.so" -} diff --git a/examples/rust-jni-demo/java/demo-app/src/main/resources/application.yml b/examples/rust-jni-demo/java/demo-app/src/main/resources/application.yml index 180b04d..dae619e 100644 --- a/examples/rust-jni-demo/java/demo-app/src/main/resources/application.yml +++ b/examples/rust-jni-demo/java/demo-app/src/main/resources/application.yml @@ -8,3 +8,5 @@ spring: logging: level: kr.go.demo: DEBUG + com.devfive.vespera.bridge: DEBUG + org.springframework.web.servlet: DEBUG diff --git a/examples/rust-jni-demo/java/settings.gradle.kts b/examples/rust-jni-demo/java/settings.gradle.kts index c068d7a..0d2aecf 100644 --- a/examples/rust-jni-demo/java/settings.gradle.kts +++ b/examples/rust-jni-demo/java/settings.gradle.kts @@ -1,3 +1,24 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + // PREFER_SETTINGS — ignore any repositories Spring Boot or other + // plugins may declare at the project level. Dogfooding the local + // build of vespera-bridge requires mavenLocal() to be visible + // even when Spring Boot would otherwise add only mavenCentral. + @Suppress("UnstableApiUsage") + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + mavenLocal() + mavenCentral() + } +} + rootProject.name = "vespera-jni-demo" include("demo-app") diff --git a/examples/rust-jni-demo/src/admin_routes/dashboard.rs b/examples/rust-jni-demo/src/admin_routes/dashboard.rs new file mode 100644 index 0000000..f5f83d7 --- /dev/null +++ b/examples/rust-jni-demo/src/admin_routes/dashboard.rs @@ -0,0 +1,31 @@ +//! Admin-only routes — registered under the `"admin"` app name. +//! Reachable from Java only when the request carries the +//! `X-Vespera-App: admin` header (or the user has installed a custom +//! [`AppNameResolver`](com.devfive.vespera.bridge.AppNameResolver) +//! that resolves to `"admin"`). + +use serde::Serialize; +use vespera::Schema; +use vespera::axum::Json; + +/// Snapshot of admin dashboard state. +#[derive(Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct DashboardInfo { + pub system: String, + pub mode: String, + pub active_users: u32, + pub uptime_seconds: u64, +} + +/// Admin dashboard endpoint — only reachable via the `"admin"` app. +#[allow(clippy::unused_async)] +#[vespera::route(get)] +pub async fn dashboard() -> Json { + Json(DashboardInfo { + system: "rust-jni-demo".to_owned(), + mode: "admin".to_owned(), + active_users: 42, + uptime_seconds: 12_345, + }) +} diff --git a/examples/rust-jni-demo/src/admin_routes/mod.rs b/examples/rust-jni-demo/src/admin_routes/mod.rs new file mode 100644 index 0000000..d87ba85 --- /dev/null +++ b/examples/rust-jni-demo/src/admin_routes/mod.rs @@ -0,0 +1 @@ +pub mod dashboard; diff --git a/examples/rust-jni-demo/src/lib.rs b/examples/rust-jni-demo/src/lib.rs index e9f53f9..f3d80eb 100644 --- a/examples/rust-jni-demo/src/lib.rs +++ b/examples/rust-jni-demo/src/lib.rs @@ -1,23 +1,60 @@ #![allow(unsafe_code)] -//! Vespera JNI integration demo. +//! Vespera JNI integration demo — **multi-app** edition. //! //! | Mode | Entry | Transport | //! |------|-------|-----------| -//! | Server | `main.rs` → `axum::serve` | TCP :3000 | -//! | JNI | `jni_app!` → Java calls `VesperaBridge.dispatch()` | In-process | +//! | Server (default app only) | `main.rs` → `axum::serve` | TCP :3000 | +//! | JNI multi-app | `jni_apps!` → Java picks app via `X-Vespera-App` header | In-process binary wire | +//! +//! Two named apps are registered: +//! +//! - **`_default`** (default — reachable without header) — the +//! document-validation API in `src/routes/`. +//! - **`admin`** (reachable with `X-Vespera-App: admin`) — the +//! admin dashboard API in `src/admin_routes/`. +//! +//! Both apps share the same Rust process / same Tokio runtime / same +//! JNI bridge — Spring picks the target per request based on the +//! configured [`AppNameResolver`]. +mod admin_routes; mod routes; use vespera::{axum, vespera}; -/// Build the application router. +/// Default app router (registered under `"_default"`). Generates +/// `openapi.json` with the document-validation routes (`/health`, +/// `/echo`, `/documents/validate`). pub fn create_app() -> axum::Router { - vespera!(title = "Document Validation API", version = "0.1.0") + vespera!( + openapi = "openapi.json", + title = "Document Validation API", + version = "0.1.0" + ) } -// Register this app for JNI dispatch — one line, no boilerplate. -vespera::jni_app!(create_app); +/// Admin app router (registered under `"admin"`). Generates a +/// separate `openapi-admin.json` so the admin surface has its own +/// OpenAPI document — multi-app callers fetch the spec for whichever +/// app they target. +pub fn admin_app() -> axum::Router { + vespera!( + dir = "admin_routes", + openapi = "openapi-admin.json", + title = "Admin Dashboard API", + version = "0.1.0" + ) +} + +// Register both apps under their respective names. Java side +// selects per request via the `X-Vespera-App` header — default app +// when the header is absent, `"admin"` app when the header reads +// `admin`. Exactly one `JNI_OnLoad` is generated for the cdylib. +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, +} // ── Tests ──────────────────────────────────────────────────────────── @@ -149,25 +186,6 @@ mod tests { assert!(!result.metadata.version.is_empty()); } - // ── Coverage: inprocess::parse_request ──────────────────────── - #[test] - fn parse_request_valid() { - use vespera::inprocess::parse_request; - let envelope = parse_request(r#"{"method":"GET","path":"/health"}"#).unwrap(); - assert_eq!(envelope.method, "GET"); - assert_eq!(envelope.path, "/health"); - assert!(envelope.query.is_empty()); - assert!(envelope.headers.is_empty()); - assert!(envelope.body.is_empty()); - } - - #[test] - fn parse_request_invalid_json() { - use vespera::inprocess::parse_request; - let err = parse_request("not json").unwrap_err(); - assert!(err.contains("invalid request envelope")); - } - // ── Coverage: inprocess::error_envelope ─────────────────────── #[test] fn error_envelope_creates_500() { @@ -219,59 +237,4 @@ mod tests { assert_eq!(parse(&json)["status"], 404); } - // ── Coverage: dispatch_json_with (explicit factory, no OnceLock) ─ - - #[test] - fn dispatch_json_with_valid() { - use vespera::inprocess::dispatch_json_with; - let rt = tokio::runtime::Runtime::new().unwrap(); - let json = dispatch_json_with(r#"{"method":"GET","path":"/health"}"#, &rt, &create_app); - let v = parse(&json); - assert_eq!(v["status"], 200); - assert!(v["body"].as_str().unwrap().contains("ok")); - } - - #[test] - fn dispatch_json_with_invalid_json() { - use vespera::inprocess::dispatch_json_with; - let rt = tokio::runtime::Runtime::new().unwrap(); - let json = dispatch_json_with("not json", &rt, &create_app); - let v = parse(&json); - assert_eq!(v["status"], 500); - assert!( - v["body"] - .as_str() - .unwrap() - .contains("invalid request envelope") - ); - } - - // ── Coverage: serialize_error ──────────────────────────────── - - #[test] - fn serialize_error_returns_500_json() { - use vespera::inprocess::serialize_error; - let json = serialize_error("test error"); - let v = parse(&json); - assert_eq!(v["status"], 500); - assert_eq!(v["body"], "test error"); - } - - // ── Coverage: register_app + dispatch_from_json ──────────── - - #[test] - fn register_and_dispatch_from_json() { - use vespera::inprocess::{dispatch_from_json, register_app}; - let rt = tokio::runtime::Runtime::new().unwrap(); - - // Before register: None branch → serialize_error - // (only works if this test runs before any other register_app call, - // which is guaranteed because OnceLock is per-process and test - // ordering means the first caller wins) - // - // After register: Some branch → dispatch_json_with - register_app(create_app); - let json = dispatch_from_json(r#"{"method":"GET","path":"/health"}"#, &rt); - assert_eq!(parse(&json)["status"], 200); - } } diff --git a/examples/rust-jni-demo/src/routes/echo.rs b/examples/rust-jni-demo/src/routes/echo.rs new file mode 100644 index 0000000..2a77340 --- /dev/null +++ b/examples/rust-jni-demo/src/routes/echo.rs @@ -0,0 +1,25 @@ +//! Echo routes — used by the manual QA / curl smoke tests for the +//! streaming JNI surfaces. The `/echo` handler returns the request +//! body verbatim with the same `Content-Type`; pair it with +//! `dispatchFullStreamingWithHeader` from the Java side to verify +//! byte-for-byte 1 GiB ↔ 1 GiB round-trips. + +use vespera::axum::body::Bytes; +use vespera::axum::http::HeaderMap; +use vespera::axum::http::header; +use vespera::axum::response::{IntoResponse, Response}; + +/// Echo the request body back as the response body verbatim, +/// preserving the incoming `Content-Type`. Mounted at `/echo` +/// (path derived from the source file name, matching the convention +/// used by `health.rs`). +#[allow(clippy::unused_async)] +#[vespera::route(post, tags = ["echo"])] +pub async fn echo(headers: HeaderMap, body: Bytes) -> Response { + let ct = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_owned(); + ([(header::CONTENT_TYPE, ct)], body).into_response() +} diff --git a/examples/rust-jni-demo/src/routes/mod.rs b/examples/rust-jni-demo/src/routes/mod.rs index 7073495..6705b35 100644 --- a/examples/rust-jni-demo/src/routes/mod.rs +++ b/examples/rust-jni-demo/src/routes/mod.rs @@ -1,2 +1,3 @@ pub mod documents; +pub mod echo; pub mod health; diff --git a/libs/vespera-bridge-gradle-plugin/build.gradle.kts b/libs/vespera-bridge-gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..e1c8062 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + `java-gradle-plugin` + `kotlin-dsl` + id("com.vanniktech.maven.publish") version "0.36.0" +} + +group = "kr.devfive" +version = "0.0.15" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +kotlin { + jvmToolchain(17) +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +gradlePlugin { + plugins { + create("vesperaBridge") { + id = "kr.devfive.vespera-bridge" + implementationClass = "kr.devfive.vespera.VesperaBridgePlugin" + displayName = "Vespera Bridge" + description = "Auto-wires Rust cdylib bundling + vespera-bridge dependency into a Spring Boot project." + tags.set(listOf("rust", "jni", "vespera", "spring", "ffi")) + } + } +} + +val shouldSign = !providers.gradleProperty("signingInMemoryKeyId").orNull.isNullOrBlank() + || !System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKeyId").isNullOrBlank() + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if (shouldSign) signAllPublications() + + coordinates( + groupId = "kr.devfive", + artifactId = "vespera-bridge-gradle-plugin", + version = project.version.toString(), + ) + + pom { + name.set("vespera-bridge-gradle-plugin") + description.set( + "Gradle plugin that wires a Vespera Rust cdylib into a Java/Spring application — " + + "auto-bundles the native library and adds the vespera-bridge dependency in one line." + ) + url.set("https://github.com/dev-five-git/vespera") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("owjs3901") + name.set("devfive") + email.set("contact@devfive.kr") + } + } + + scm { + url.set("https://github.com/dev-five-git/vespera") + connection.set("scm:git:git://github.com/dev-five-git/vespera.git") + developerConnection.set("scm:git:ssh://git@github.com:dev-five-git/vespera.git") + } + } +} diff --git a/libs/vespera-bridge-gradle-plugin/gradle.properties b/libs/vespera-bridge-gradle-plugin/gradle.properties new file mode 100644 index 0000000..ae3f1a7 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx1024m -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.jar b/libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/libs/vespera-bridge-gradle-plugin/gradlew b/libs/vespera-bridge-gradle-plugin/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/libs/vespera-bridge-gradle-plugin/gradlew.bat b/libs/vespera-bridge-gradle-plugin/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/vespera-bridge-gradle-plugin/settings.gradle.kts b/libs/vespera-bridge-gradle-plugin/settings.gradle.kts new file mode 100644 index 0000000..7cc5cbc --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "vespera-bridge-gradle-plugin" diff --git a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt new file mode 100644 index 0000000..92a33cf --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt @@ -0,0 +1,48 @@ +package kr.devfive.vespera + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property + +/** + * DSL for the `kr.devfive.vespera-bridge` plugin. + * + * ```kotlin + * vespera { + * crateName.set("my_rust_lib") + * cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + * bridgeVersion.set("0.0.15") + * autoBuildCargo.set(false) // default: opt-in + * } + * ``` + */ +abstract class VesperaBridgeExtension { + /** + * Cargo crate name of the Rust cdylib — used to derive the + * platform-specific library filename + * (`{name}.dll` / `lib{name}.so` / `lib{name}.dylib`). + */ + abstract val crateName: Property + + /** + * Workspace root containing the `target/release/` directory with + * the built cdylib. Typically `../..` relative to a sample + * `examples//java/demo-app/` project layout. + */ + abstract val cargoRoot: DirectoryProperty + + /** + * Version of `kr.devfive:vespera-bridge` to add as an + * `implementation` dependency. Must be set explicitly — the + * plugin does not guess a default to avoid silent upgrades. + */ + abstract val bridgeVersion: Property + + /** + * When `true`, registers a `cargoBuild` task that runs + * `cargo build --release -p ` and wires it as a + * dependency of `bundleNativeLib`. Defaults to `false` (opt-in) + * — most CI pipelines build Rust separately and don't want the + * Java build to invoke cargo implicitly. + */ + abstract val autoBuildCargo: Property +} diff --git a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt new file mode 100644 index 0000000..4de0053 --- /dev/null +++ b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt @@ -0,0 +1,160 @@ +package kr.devfive.vespera + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.Exec +import java.io.File + +/** + * Gradle plugin that wires a Vespera Rust cdylib into a Java + * application: + * + * 1. Registers a `bundleNativeLib` task that copies the cdylib from + * `/target/release/` into + * `build/resources/main/native/-/` so + * `VesperaBridge.init(...)` can extract it at runtime. + * 2. Wires `bundleNativeLib` into `processResources`. + * 3. Adds `kr.devfive:vespera-bridge:` as an + * `implementation` dependency. + * 4. Optionally (`autoBuildCargo = true`) registers a `cargoBuild` + * task that invokes `cargo build --release -p ` before + * `bundleNativeLib`. + * + * Usage: + * + * ```kotlin + * plugins { + * id("kr.devfive.vespera-bridge") version "0.0.15" + * } + * + * vespera { + * crateName.set("my_rust_lib") + * cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + * bridgeVersion.set("0.0.15") + * } + * ``` + */ +class VesperaBridgePlugin : Plugin { + override fun apply(project: Project) { + val ext = project.extensions + .create("vespera", VesperaBridgeExtension::class.java) + ext.autoBuildCargo.convention(false) + + // Compute platform-derived values eagerly (host machine info). + val os = detectOs() + val arch = detectArch() + val targetSubdir = "resources/main/native/$os-$arch" + + // Lazy file references — evaluated at task execution. + val cdylibFile = project.provider { + val root = ext.cargoRoot.get().asFile + val name = ext.crateName.get() + File(root, "target/release/" + mapLibraryName(os, name)) + } + + val cargoBuildTask = project.tasks.register( + "cargoBuild", + Exec::class.java, + object : org.gradle.api.Action { + override fun execute(t: Exec) { + t.group = "vespera" + t.description = "Build the Rust cdylib via `cargo build --release`." + t.workingDir = ext.cargoRoot.get().asFile + t.commandLine("cargo", "build", "-p", ext.crateName.get(), "--release") + // Up-to-date check: re-run on any .rs file or Cargo.lock change. + val rustSources = project.fileTree( + ext.cargoRoot.get().asFile.resolve("src") + ) + rustSources.include("**/*.rs") + t.inputs.files(rustSources) + t.inputs.file(ext.cargoRoot.get().asFile.resolve("Cargo.lock")) + t.outputs.file(cdylibFile) + } + } + ) + + val bundleTask = project.tasks.register( + "bundleNativeLib", + Copy::class.java, + object : org.gradle.api.Action { + override fun execute(t: Copy) { + t.group = "vespera" + t.description = + "Copy the built cdylib into src/main/resources/native/-/." + t.from(cdylibFile) + t.into(project.layout.buildDirectory.dir(targetSubdir)) + t.doFirst(object : org.gradle.api.Action { + override fun execute(@Suppress("UNUSED_PARAMETER") task: Task) { + val src = cdylibFile.get() + require(src.exists()) { + "Native library not found: $src\n" + + "Run: cargo build -p ${ext.crateName.get()} --release " + + "(or set vespera.autoBuildCargo = true)" + } + } + }) + } + } + ) + + // Wire cargoBuild → bundleNativeLib when opt-in. + bundleTask.configure(object : org.gradle.api.Action { + override fun execute(t: Copy) { + t.dependsOn( + project.provider { + if (ext.autoBuildCargo.get()) listOf(cargoBuildTask) else emptyList() + } + ) + } + }) + + // Hook into Java resource processing + dependency wiring. + project.afterEvaluate(object : org.gradle.api.Action { + override fun execute(p: Project) { + p.tasks.findByName("processResources")?.dependsOn(bundleTask) + + // Repository configuration is intentionally left to + // the user's settings.gradle.kts (dependencyResolution + // Management) — Gradle's "fail-on-project-repos" mode + // requires us not to mutate project.repositories from + // a plugin. Users typically add mavenCentral() (and + // mavenLocal() for development) at the settings level. + val version = ext.bridgeVersion.orNull + ?: error( + "vespera.bridgeVersion must be set explicitly. " + + "Example: vespera { bridgeVersion.set(\"0.0.15\") }" + ) + p.dependencies.add( + "implementation", + "kr.devfive:vespera-bridge:$version", + ) + } + }) + } + + private fun detectOs(): String { + val os = System.getProperty("os.name", "").lowercase() + return when { + "win" in os -> "windows" + "mac" in os || "darwin" in os -> "macos" + else -> "linux" + } + } + + private fun detectArch(): String { + val arch = System.getProperty("os.arch", "").lowercase() + return when { + "amd64" in arch || "x86_64" in arch -> "x86_64" + "aarch64" in arch || "arm64" in arch -> "aarch64" + else -> arch + } + } + + private fun mapLibraryName(os: String, name: String): String = when (os) { + "windows" -> "$name.dll" + "macos" -> "lib$name.dylib" + else -> "lib$name.so" + } +} diff --git a/libs/vespera-bridge/README.md b/libs/vespera-bridge/README.md new file mode 100644 index 0000000..add6b8e --- /dev/null +++ b/libs/vespera-bridge/README.md @@ -0,0 +1,409 @@ +# vespera-bridge + +JNI bridge that lets a Java/Spring application embed a Rust [`vespera`](../../) axum router in-process — no TCP, no JSON envelope overhead, raw bytes from end to end. + +```xml + + kr.devfive + vespera-bridge + 0.0.15 + +``` + +```kotlin +dependencies { + implementation("kr.devfive:vespera-bridge:0.0.15") +} +``` + +### One-line setup via the Gradle plugin (recommended) + +For Spring Boot apps the [`kr.devfive.vespera-bridge`](../vespera-bridge-gradle-plugin/) Gradle plugin replaces the ~22 lines of native-library-bundling boilerplate with a 5-line `vespera { ... }` block: + +```kotlin +plugins { + id("kr.devfive.vespera-bridge") version "0.0.15" +} + +vespera { + crateName.set("my_rust_lib") + cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + bridgeVersion.set("0.0.15") +} +``` + +The plugin auto-wires `bundleNativeLib` (cdylib → `resources/native/-/`), `processResources` dependency, and the `vespera-bridge` `implementation` dependency. See [`examples/rust-jni-demo/java/demo-app/build.gradle.kts`](../../examples/rust-jni-demo/java/demo-app/build.gradle.kts) for a real working example (the demo dogfoods the plugin end-to-end with a 1 MiB SHA256-verified bidirectional streaming round-trip). + +## Two-line integration + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); // ← loads cdylib (bundled or system) + SpringApplication.run(MyApp.class, args); + } +} +``` + +`VesperaProxyController` is **autoconfigured** (via Spring Boot `AutoConfiguration.imports`) and forwards every HTTP request to Rust. You write zero controller code on the Java side, **zero `application.yml` config**, and **zero `import` lines** beyond the Spring Boot starter — the routes published in vespera's generated `openapi.json` are reachable at the same URLs through Spring. + +## Zero-config defaults + +Out of the box the autoconfigure module wires up: + +| Concern | Default | Override | +|---|---|---| +| **App selection** | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom [`AppNameResolver`](src/main/java/com/devfive/vespera/bridge/AppNameResolver.java) bean | +| **Dispatch mode** | [`BIDIRECTIONAL_STREAMING`](src/main/java/com/devfive/vespera/bridge/DispatchMode.java) for every request — safe for any payload size, transparent for the Rust router | Custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean | +| **URL pattern** | Single `@RequestMapping("/**")` catch-all — every vespera router URL exactly mirrors the published OpenAPI path | Set `vespera.bridge.controller-enabled: false` and supply your own controller | +| **Body handling** | Servlet `InputStream` straight through to Rust (no buffering) for streaming modes; full read for sync/async | (encoded by the chosen `DispatchMode`) | + +Why `BIDIRECTIONAL_STREAMING` as the default mode? It's the only mode that processes every payload size correctly without dispatch-time hints: + +- **Tiny request / tiny response** (`/health` → `"ok"`): processed as a single chunk, negligible overhead. +- **Small JSON RPC** (`/users` → `{...}`): single chunk both ways. +- **Multi-GB upload + multi-GB download**: chunk-bounded both ways, ~32 KiB resident. + +This means the Spring endpoints **always** mirror vespera's `openapi.json` — there is no URL prefix or mode-detection heuristic that could diverge from the Rust router's view of the world. + +## Customization + +All defaults are individually replaceable. Start with properties for minor tweaks, drop in a `@Bean` for serious behavior changes, or disable the autoconfigured controller entirely. + +### 1. Tweak via `application.yml` + +```yaml +vespera: + bridge: + app-header: X-My-App # change the header that selects the app + controller-enabled: true # set false to disable our controller +``` + +### 2. Custom app-selection strategy + +Resolve the app name however you like — URL path segment, subdomain, JWT claim, … + +```java +@Bean +public AppNameResolver myAppResolver() { + // Example: app name comes from the FIRST path segment + // /admin/dashboard → app "admin", path "/dashboard" + // /public/info → app "public", path "/info" + return request -> { + String uri = request.getRequestURI(); + if (uri.startsWith("/admin/")) return "admin"; + if (uri.startsWith("/public/")) return "public"; + return null; // default app + }; +} +``` + +Spring autoconfigure's `@ConditionalOnMissingBean` automatically disables `HeaderAppNameResolver` when you supply your own bean — no further config needed. + +### 3. Custom dispatch-mode policy + +Pick `SYNC` for tiny JSON RPC, `BIDIRECTIONAL_STREAMING` for everything else: + +```java +@Bean +public DispatchModeResolver myModeResolver() { + return request -> { + long contentLength = request.getContentLengthLong(); + if (contentLength >= 0 && contentLength < 4096 + && "application/json".equals(request.getContentType())) { + return DispatchMode.SYNC; + } + return DispatchMode.BIDIRECTIONAL_STREAMING; + }; +} +``` + +### 4. BYO controller + +Disable our controller entirely and route however you want. The `VesperaBridge` native methods remain available for direct use: + +```yaml +vespera: + bridge: + controller-enabled: false +``` + +```java +@RestController +public class MyController { + @PostMapping("/api/admin/{path}") + public ResponseEntity adminRoute(@PathVariable String path, @RequestBody byte[] body) { + byte[] wire = VesperaBridge.encodeRequest( + "admin", // ← explicit app name + "POST", "/" + path, null, + Map.of("content-type", "application/json"), + body); + byte[] resp = VesperaBridge.dispatchBytes(wire); + DecodedResponse d = VesperaBridge.decodeResponse(resp); + return ResponseEntity.status(d.status()).body(d.body()); + } +} +``` + +## Multi-app routing + +Register multiple named apps on the Rust side with `vespera::jni_apps!`: + +```rust +// src/lib.rs of your cdylib crate +pub fn create_app() -> axum::Router { vespera!(title = "Default") } +pub fn admin_app() -> axum::Router { vespera!(dir = "admin_routes", title = "Admin") } +pub fn public_app() -> axum::Router { vespera!(dir = "public_routes", title = "Public") } + +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +From the Java side, the default `HeaderAppNameResolver` selects an app per request: + +```bash +# Default app (no header) +curl http://localhost:8080/health + +# Admin app +curl -H "X-Vespera-App: admin" http://localhost:8080/dashboard + +# Public app +curl -H "X-Vespera-App: public" http://localhost:8080/info +``` + +Each app's URLs are independent — the same `/users` path can mean different things in `admin` vs `public` apps. Unknown app names return `404`; invalid app names (special characters, > 64 bytes) return `400`. + +See [`examples/rust-jni-demo/`](../../examples/rust-jni-demo/) for a complete working example with two registered apps, including an admin dashboard route reachable only via the `admin` app. + +## Binary wire format + +Both request and response use the same length-prefixed layout: + +``` +bytes 0..4 : u32 BE = header_json byte length N +bytes 4..4+N : UTF-8 JSON + (request) { "v":1, "method", "path", + "query"?, "headers"? } + (response) { "v":1, "status", "headers", + "metadata" } +bytes 4+N.. : raw body bytes (UTF-8 text or binary — + no encoding applied) +``` + +- `"v":1` is the protocol version. Mismatched versions return a `400` wire response with an explanatory plain-text body. +- Multi-valued response headers (e.g. `set-cookie`) render as JSON arrays so semantics are preserved — they're never comma-joined. +- All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response with status `4xx` / `5xx`, so the decoder never has to special-case errors. + +## Four dispatch modes + +`VesperaBridge` exposes four native methods that all share the same +wire format, same registered router, and same panic-safe +`catch_unwind` discipline: + +| Method | Mode | Java side return | Memory footprint | +|---|---|---|---| +| `dispatchBytes(byte[])` | sync | `byte[]` (header + body) | full body in memory | +| `dispatchAsync(CompletableFuture, byte[])` | async (`CompletableFuture`) | `void` (future completes) | full body in memory | +| `dispatchStreaming(byte[], OutputStream)` | sync, response-streaming | `byte[]` (header only) | chunk-bounded response | +| `dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync, **bidirectional streaming** | `byte[]` (header only) | chunk-bounded both ways | + +Pick the mode that matches your workload: +- Small JSON RPC, single request/response → `dispatchBytes` +- Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture` +- Large download / streaming response (video, PDF, server-sent events) → `dispatchStreaming` + `OutputStream` +- **Large upload + large download** (file transfer proxy, video transcoding, 1 GB ↔ 1 GB) → `dispatchFullStreaming` + `InputStream` + `OutputStream` + +## Direct API (without the proxy controller) + +For custom integrations bypassing Spring: + +```java +import com.devfive.vespera.bridge.VesperaBridge; +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; + +// 1. Initialise once at startup +VesperaBridge.init("my_rust_lib"); + +// 2. Encode a request +byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", + "/documents/validate", + /* query */ null, + Map.of("content-type", "application/json"), + "{\"title\":\"…\"}".getBytes(StandardCharsets.UTF_8)); + +// 3. Dispatch through Rust +byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest); + +// 4. Decode +DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); +System.out.println(resp.status()); // 200 +System.out.println(resp.headers()); // { "content-type": "application/json", … } +System.out.println(new String(resp.body())); // the raw response body +``` + +### Async dispatch (`CompletableFuture`) + +```java +import java.util.concurrent.CompletableFuture; + +byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/documents/validate", null, + Map.of("content-type", "application/json"), + body); + +CompletableFuture future = VesperaBridge.dispatch(wireRequest); +// Non-blocking — the calling thread continues; the future is +// completed from a Tokio worker thread. + +future.thenAccept(wireResponse -> { + DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); + System.out.println("Status: " + resp.status()); +}); + +// Or block synchronously: +byte[] wireResponse = future.get(); +``` + +Always-complete contract: the future is **always** completed with a +valid wire response, even on Rust panics or JNI conversion failures. +You will never see a dangling future. + +> Cancellation note: `future.cancel(true)` marks the Java side as +> cancelled but does not abort the in-flight Rust dispatch in this +> release. The Rust task continues to completion and its result is +> discarded. + +### Streaming dispatch (large bodies, file uploads/downloads) + +```java +import java.io.ByteArrayOutputStream; + +byte[] wireRequest = VesperaBridge.encodeRequest( + "GET", "/files/large.pdf", null, Map.of(), new byte[0]); + +try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) { + // Body bytes stream into `sink` chunk-by-chunk during the call. + byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink); + + DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly); + System.out.println("Status: " + meta.status()); + System.out.println("Body size: " + sink.size()); +} +``` + +### Bidirectional streaming (large upload + large download) + +```java +import java.io.InputStream; +import java.io.OutputStream; + +// Request body comes from anywhere — file, socket, HTTP stream: +try (InputStream upload = Files.newInputStream(Path.of("huge.mp4")); + OutputStream download = Files.newOutputStream(Path.of("transcoded.mp4"))) { + + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/transcode", null, + Map.of("content-type", "video/mp4")); + + byte[] respHeader = VesperaBridge.dispatchFullStreaming( + wireHeader, upload, download); + + DecodedResponse meta = VesperaBridge.decodeResponse(respHeader); + System.out.println("Status: " + meta.status()); + // download already contains the transcoded video. +} +``` + +Memory characteristics: **roughly 16 KiB chunk buffer + a 16-slot +mpsc channel buffer** in Rust, plus normal JVM `byte[]` chunks. A +1 GiB upload paired with a 1 GiB download runs in ~500 KiB resident +memory on each side. Backpressure is enforced naturally — if axum +reads slowly, `InputStream.read()` blocks on the bounded channel. + +### Server-side response streaming (Spring `StreamingResponseBody`) + +Pair `dispatchStreaming` with `StreamingResponseBody` for true +server-side streaming — the JVM and Rust both process chunks +without ever holding the full body in memory: + +```java +@GetMapping("/download/{name}") +public ResponseEntity download(@PathVariable String name) { + byte[] wireReq = VesperaBridge.encodeRequest( + "GET", "/files/" + name, null, Map.of(), new byte[0]); + + // We need status/headers before Spring commits the response — + // call streaming once with a buffered sink to get the header, + // then stream the actual response. (For pure pass-through, use + // a custom controller that wires Spring's HttpServletResponse + // OutputStream directly.) + StreamingResponseBody body = output -> { + VesperaBridge.dispatchStreaming(wireReq, output); + }; + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(body); +} +``` + +### Binary upload / download + +The wire format carries bytes verbatim — no base64, no transcoding. A multipart file upload reaches the Rust `axum::extract::Multipart` extractor byte-for-byte: + +```java +byte[] pdf = Files.readAllBytes(Path.of("report.pdf")); +byte[] wire = VesperaBridge.encodeRequest( + "POST", "/upload", null, + Map.of("content-type", "application/octet-stream"), + pdf); +DecodedResponse resp = VesperaBridge.decodeResponse( + VesperaBridge.dispatchBytes(wire)); +assert Arrays.equals(pdf, resp.body()); // exact round-trip +``` + +A Rust handler returning a binary response (e.g. `image/png`) flows the same way: `VesperaProxyController` inspects the response `Content-Type` and returns `ResponseEntity` for binary content, `ResponseEntity` for text-like content. + +## VesperaProxyController behaviour + +`@RequestMapping("/**")` catches every HTTP request, regardless of method or content type, and: + +1. Collects all incoming headers (lowercased keys). +2. Reads the body as `byte[]` (Spring's `@RequestBody byte[]`, `consumes = MediaType.ALL_VALUE`). +3. Encodes via `VesperaBridge.encodeRequest(...)` → `dispatchBytes(byte[])`. +4. Decodes via `VesperaBridge.decodeResponse(byte[])`. +5. Returns `ResponseEntity` for text-like `Content-Type` (e.g. `text/*`, `application/json`, `+json`, `+xml`, `application/xml`, `application/javascript`, `application/yaml`, `application/x-www-form-urlencoded`, `application/graphql`). +6. Returns `ResponseEntity` for everything else. + +Missing `Content-Type` defaults to "text" — matching the long-standing Vespera convention of treating unspecified content as JSON-shaped. + +## Native library loading + +`VesperaBridge.init("crateName")` tries two paths in order: + +1. **Bundled** — looks up `native/{os}-{arch}/{libname}` inside the running JAR's classpath. If present, the file is extracted to a temp file (auto-deleted on JVM exit) and loaded via `System.load`. +2. **Fallback** — `System.loadLibrary("crateName")` searches `java.library.path`. + +The supported triples are `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`. Place the cdylib at `src/main/resources/native/{os}-{arch}/` to bundle it; see [`examples/rust-jni-demo/java/demo-app/build.gradle.kts`](../../examples/rust-jni-demo/java/demo-app/build.gradle.kts) for a working Gradle task. + +## End-to-end example + +See [`examples/rust-jni-demo`](../../examples/rust-jni-demo/) for a complete Rust + Spring Boot integration including build scripts, native bundling, and a curl smoke test. + +## Migrating from the JSON-envelope bridge (≤ 0.0.13) + +The pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies. Migration: + +| Before | After | +|---|---| +| `VesperaBridge.dispatch(json)` | `encodeRequest(...)` → `dispatchBytes(...)` → `decodeResponse(...)` | +| `body_bytes_b64` field on the response JSON | raw body bytes after the wire header (no base64) | +| ~33 % size overhead on binary bodies | zero overhead | + +Existing users of `VesperaProxyController` need no code change — the controller was rewritten to the new wire path internally. Direct callers of `VesperaBridge.dispatch(String)` must update; the old method was removed in 0.0.14. diff --git a/libs/vespera-bridge/build.gradle.kts b/libs/vespera-bridge/build.gradle.kts index c1c1cf1..a23aedf 100644 --- a/libs/vespera-bridge/build.gradle.kts +++ b/libs/vespera-bridge/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "kr.devfive" -version = "0.0.14" +version = "0.0.15" java { toolchain { @@ -29,30 +29,24 @@ repositories { dependencies { api("org.springframework.boot:spring-boot-starter-web:3.2.5") api("com.fasterxml.jackson.core:jackson-databind:2.17.0") + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2") } -// TODO: Remove after confirming CI secrets work -logger.lifecycle("=== Maven Central Publish Debug ===") -listOf( - "mavenCentralUsername", - "mavenCentralPassword", - "signingInMemoryKeyId", - "signingInMemoryKey", - "signingInMemoryKeyPassword", -).forEach { key -> - val value = providers.gradleProperty(key).orNull - val status = when { - value == null -> "MISSING" - value.isBlank() -> "EMPTY" - else -> "OK (${value.length} chars)" - } - logger.lifecycle(" [ENV CHECK] $key = $status") +tasks.named("test") { + useJUnitPlatform() } -logger.lifecycle("==================================") + +// Gate Maven Central signing on the presence of in-memory signing +// credentials so `publishToMavenLocal` works for development / +// dogfooding without GPG keys, while production releases still sign. +val shouldSign = !providers.gradleProperty("signingInMemoryKeyId").orNull.isNullOrBlank() + || !System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKeyId").isNullOrBlank() mavenPublishing { publishToMavenCentral(automaticRelease = true) - signAllPublications() + if (shouldSign) signAllPublications() coordinates( groupId = "kr.devfive", diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/AppNameResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/AppNameResolver.java new file mode 100644 index 0000000..ac16e54 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/AppNameResolver.java @@ -0,0 +1,36 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Strategy for picking which named app should receive an incoming + * HTTP request. Supports the multi-app routing surface exposed by + * the Rust {@code register_app_named} API. + * + *

The autoconfigured default is {@link HeaderAppNameResolver} — + * it reads the app name from the {@code X-Vespera-App} request + * header (configurable via the {@code vespera.bridge.app-header} + * property), falling back to the default app when the header is + * absent. This keeps Spring endpoints aligned with the URLs + * published in vespera's {@code openapi.json} — there is no path + * prefix that diverges from the Rust router's view of the world. + * + *

Users who want path-based, subdomain-based, or any other app + * selection can register a custom {@code AppNameResolver} bean; + * the autoconfigure module's {@code @ConditionalOnMissingBean} + * gate automatically disables the default in that case. + * + *

Implementations must be safe to call from multiple servlet + * threads concurrently. + */ +@FunctionalInterface +public interface AppNameResolver { + + /** + * Resolve the app name for the supplied request. + * + * @return app name, or {@code null} / empty / whitespace to + * route to the default app + */ + String resolveAppName(HttpServletRequest request); +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java new file mode 100644 index 0000000..e7a8001 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java @@ -0,0 +1,28 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Default {@link DispatchModeResolver} — always returns + * {@link DispatchMode#BIDIRECTIONAL_STREAMING}. + * + *

This is the safest universal default: every payload size + * (including 0-byte requests and tiny JSON bodies) is processed + * correctly through the bidirectional streaming JNI path, and the + * Spring endpoints exactly mirror the URLs in vespera's generated + * {@code openapi.json}. No path-based mode discrimination means no + * surprise divergence from the Rust router's view. + * + *

Replace this with a custom {@link DispatchModeResolver} bean if + * your application needs different modes for different routes + * (e.g. sync for sub-KB JSON RPC, async for parallel I/O + * coordination). + */ +public final class BidirectionalStreamingDispatchModeResolver + implements DispatchModeResolver { + + @Override + public DispatchMode resolveMode(HttpServletRequest request) { + return DispatchMode.BIDIRECTIONAL_STREAMING; + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java new file mode 100644 index 0000000..192520f --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java @@ -0,0 +1,60 @@ +package com.devfive.vespera.bridge; + +/** + * How {@link VesperaProxyController} dispatches an incoming HTTP + * request through the Rust JNI bridge. + * + *

The default {@link DispatchModeResolver} returns + * {@link #BIDIRECTIONAL_STREAMING} for every request so that the + * Spring side stays transparent to the vespera Rust router — the + * routes published in the generated {@code openapi.json} are reached + * via the same URLs, regardless of whether the underlying handler + * emits a small JSON body or streams a multi-gigabyte file. Users + * who want a different policy (sync for small JSON RPC, async for + * heavy I/O coordination, …) can register a custom + * {@link DispatchModeResolver} bean — {@code @ConditionalOnMissingBean} + * ensures the default is automatically disabled. + */ +public enum DispatchMode { + /** + * Synchronous dispatch via + * {@link VesperaBridge#dispatchBytes(byte[])}. Full request + * body is materialised in memory before dispatch; full response + * body is materialised before return. Smallest overhead for + * tiny request/response pairs (typical JSON RPC). + */ + SYNC, + + /** + * Asynchronous dispatch via + * {@link VesperaBridge#dispatchAsync(java.util.concurrent.CompletableFuture, byte[])}. + * Returns a {@link java.util.concurrent.CompletableFuture} + * completed from a Tokio worker thread. Useful when the + * controller wants to coordinate multiple parallel dispatches. + */ + ASYNC, + + /** + * Response-streaming dispatch via + * {@link VesperaBridge#dispatchStreamingWithHeader(byte[], + * java.util.function.Consumer, java.io.OutputStream)}. + * Request body is materialised; response body streams + * chunk-by-chunk into the servlet output stream. Suitable for + * large downloads + small uploads (file serving, video). + */ + STREAMING, + + /** + * Bidirectional streaming dispatch via + * {@link VesperaBridge#dispatchFullStreamingWithHeader(byte[], + * java.util.function.Consumer, java.io.InputStream, + * java.io.OutputStream)}. + * Both request and response bodies stream chunk-by-chunk. + * This is the default mode — it works correctly + * for every payload size (small requests are processed as a + * single chunk), so callers see the vespera Rust router's + * endpoints exactly as published in {@code openapi.json} with + * no special configuration. + */ + BIDIRECTIONAL_STREAMING, +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java new file mode 100644 index 0000000..b1949f2 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java @@ -0,0 +1,36 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Strategy for deciding which {@link DispatchMode} should serve an + * incoming HTTP request. + * + *

The autoconfigured default returns + * {@link DispatchMode#BIDIRECTIONAL_STREAMING} for every request, + * which works correctly across all payload sizes (small requests + * are processed as a single chunk) and keeps Spring endpoints + * aligned with the URLs published in vespera's {@code openapi.json} + * — no path-based mode selection that would diverge from the Rust + * router's view. + * + *

Users who want a mixed policy (e.g. {@link DispatchMode#SYNC} + * for sub-KB JSON RPC, {@link DispatchMode#STREAMING} for paths + * matching {@code /files/**}, {@link DispatchMode#ASYNC} for + * everything else) can register a custom {@code DispatchModeResolver} + * bean — the autoconfigure module's {@code @ConditionalOnMissingBean} + * gate automatically disables the default. + * + *

Implementations must be safe to call from multiple servlet + * threads concurrently. + */ +@FunctionalInterface +public interface DispatchModeResolver { + + /** + * Pick the dispatch mode for the supplied request. + * + * @return non-null {@link DispatchMode} value + */ + DispatchMode resolveMode(HttpServletRequest request); +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java new file mode 100644 index 0000000..3f569f4 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java @@ -0,0 +1,35 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Default {@link AppNameResolver} — reads the app name from a + * configurable HTTP request header (typically + * {@code X-Vespera-App}). + * + *

When the header is absent (or empty / whitespace), this + * resolver returns {@code null} so the dispatch layer falls back + * to the default app registered via the Rust + * {@code register_app} API. + */ +public final class HeaderAppNameResolver implements AppNameResolver { + + private final String headerName; + + /** + * @param headerName HTTP header to inspect (e.g. + * {@code "X-Vespera-App"}) + */ + public HeaderAppNameResolver(String headerName) { + if (headerName == null || headerName.isBlank()) { + throw new IllegalArgumentException( + "headerName must not be null or blank"); + } + this.headerName = headerName; + } + + @Override + public String resolveAppName(HttpServletRequest request) { + return request.getHeader(headerName); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java index 9ba008f..f4ce32d 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java @@ -1,29 +1,89 @@ package com.devfive.vespera.bridge; -import java.io.*; -import java.nio.file.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Objects; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; /** * JNI bridge to any Rust cdylib built with vespera's JNI feature. * - *

Usage - single line in your Spring Boot app

+ *

Wire format — both request and response use the + * same layout: + *

+ *   bytes 0..4    : u32 BE = header_json byte length N
+ *   bytes 4..4+N  : UTF-8 JSON header
+ *                     (request)  { "v":1, "method", "path",
+ *                                  "query"?, "headers"? }
+ *                     (response) { "v":1, "status", "headers",
+ *                                  "metadata" }
+ *   bytes 4+N..   : raw body bytes (UTF-8 text or binary —
+ *                   no encoding applied)
+ * 
+ * + *

Usage — single line in your Spring Boot app: *

{@code
  * VesperaBridge.init("rust_jni_demo");
  * }
* - *

That's it. The proxy controller ({@link VesperaProxyController}) - * is auto-configured by Spring's component scan when this JAR is on - * the classpath. + *

The proxy controller ({@link VesperaProxyController}) is + * auto-configured by Spring's component scan when this JAR is on the + * classpath. */ public class VesperaBridge { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int WIRE_VERSION = 1; private static volatile boolean loaded = false; + /** + * Decoded wire-format response. + * + * @param status HTTP status code from the upstream router + * @param headers response headers; each value is either a + * {@link String} (single-valued) or a + * {@link List List<String>} + * (multi-valued, e.g. {@code set-cookie}) + * @param metadata vespera metadata (e.g. {@code version}) + * @param body raw response body bytes + * @param validationErrors Vespera-validation failures hoisted from + * a {@code 422} JSON body so callers can + * read them without a second JSON parse. + * {@code null} when the response is not a + * 422 or doesn't carry the {@code + * validation_errors} wire header field. + * Each entry typically has {@code path}, + * {@code code}, and {@code message} keys. + */ + public record DecodedResponse( + int status, + Map headers, + Map metadata, + byte[] body, + List> validationErrors) {} + /** * Initialize the Rust engine. Tries bundled (JAR-embedded) first, * falls back to {@code java.library.path}. * - * @param libraryName Cargo crate name (e.g. "rust_jni_demo") + * @param libraryName Cargo crate name (e.g. {@code "rust_jni_demo"}) */ public static synchronized void init(String libraryName) { if (loaded) return; @@ -36,9 +96,351 @@ public static synchronized void init(String libraryName) { } /** - * Dispatch a full HTTP-like request through the Rust axum router. + * Dispatch a wire-format HTTP-like request through the Rust axum + * router (synchronous — blocks the calling + * thread). See {@link VesperaBridge class-level docs} for the + * wire layout. + * + * @param wireRequest length-prefixed binary wire request + * @return length-prefixed binary wire response + */ + public static native byte[] dispatchBytes(byte[] wireRequest); + + /** + * Asynchronous variant of {@link #dispatchBytes(byte[])}. Returns + * immediately after spawning the dispatch on Rust's Tokio runtime; + * the supplied {@link CompletableFuture} is completed with the + * wire-format response bytes from a runtime worker thread. + * + *

Contract (always-complete): the future is always completed + * with a valid wire response. Panics in the Rust handler are + * converted to a `500` wire response; JNI conversion failures to + * a `400` wire response. The future is never left dangling. + * + *

Cancellation is not propagated to the Rust task in this + * release: {@code future.cancel(true)} will mark the future as + * cancelled on the Java side, but the in-flight Rust dispatch + * continues to completion (and its result is discarded). + * + * @param future the future to complete with the wire response + * @param wireRequest length-prefixed binary wire request + */ + public static native void dispatchAsync( + CompletableFuture future, byte[] wireRequest); + + /** + * Convenience wrapper around {@link #dispatchAsync} that allocates + * the {@link CompletableFuture} and returns it. + * + * @param wireRequest length-prefixed binary wire request + * @return future that resolves to the wire-format response bytes + */ + public static CompletableFuture dispatch(byte[] wireRequest) { + CompletableFuture future = new CompletableFuture<>(); + dispatchAsync(future, wireRequest); + return future; + } + + /** + * Streaming binary wire-format JNI dispatch. The + * dispatch runs synchronously on the calling thread (like + * {@link #dispatchBytes(byte[])}) but emits the response body + * chunk-by-chunk to {@code outputStream.write(byte[])} + * — neither the Rust side nor the JVM ever holds the full body in + * memory at once. + * + *

Returns the wire-format header bytes only + * (length-prefixed JSON: status, headers, metadata). The body + * arrived via {@code outputStream} while the dispatch was in + * flight. + * + *

Failure modes (malformed wire, panic in Rust, no app + * registered) return a regular {@code error_wire(...)} response + * (header + small plain-text body) and the {@code outputStream} + * is not written to. Callers can detect a + * streaming error by checking whether the returned bytes carry a + * non-empty body via {@link #decodeResponse(byte[])}. + * + * @param wireRequest length-prefixed binary wire request + * @param outputStream sink for response body chunks + * @return wire-format header bytes (body lives on the OutputStream) + */ + public static native byte[] dispatchStreaming(byte[] wireRequest, OutputStream outputStream); + + /** + * Bidirectional streaming binary wire-format JNI + * dispatch — both request body (from {@code inputStream}) and + * response body (to {@code outputStream}) are processed + * chunk-by-chunk. Neither side materialises the full body in + * memory, so a 1 GiB upload paired with a 1 GiB download runs in + * roughly {@code O(chunk_size)} RAM. + * + *

Wire envelope contract: + *

    + *
  • {@code wireRequestHeader} is a wire-format request + * without a body — just the 4-byte length + * prefix + JSON header (method, path, query, headers). + * Use {@link #encodeRequest(String, String, String, java.util.Map, byte[])} + * with an empty {@code body} array.
  • + *
  • The request body bytes flow through {@code inputStream} + * — Rust calls {@code inputStream.read(byte[])} repeatedly + * (16 KiB at a time) until EOF.
  • + *
  • The response body bytes flow through {@code outputStream} + * — Rust calls {@code outputStream.write(byte[])} for each + * axum body frame.
  • + *
+ * + *

Returns the wire-format header bytes only + * (status, headers, metadata). Decode with + * {@link #decodeResponse(byte[])} to read the status and headers + * — the body has already been written to {@code outputStream}. + * + *

Failure modes (malformed wire, panic in Rust, no app + * registered) return a regular {@code error_wire(...)} response + * (header + small plain-text body) and neither + * stream is touched. + * + * @param wireRequestHeader length-prefixed binary wire request + * header (no body) + * @param inputStream source for request body chunks + * @param outputStream sink for response body chunks + * @return wire-format header bytes (body lives on the + * {@code outputStream}) + */ + public static native byte[] dispatchFullStreaming( + byte[] wireRequestHeader, + InputStream inputStream, + OutputStream outputStream); + + /** + * Convenience encoder for the bidirectional streaming variant — + * produces a wire-format header with an empty body, suitable for + * passing to {@link #dispatchFullStreaming(byte[], InputStream, OutputStream)}. + * + * @param method HTTP method (uppercase) + * @param path URL path + * @param query raw query string (may be {@code null}) + * @param headers request headers + * @return wire bytes with the JSON header and no body + */ + public static byte[] encodeRequestHeader( + String method, + String path, + String query, + java.util.Map headers) { + return encodeRequestHeader(null, method, path, query, headers); + } + + /** + * Same as {@link #encodeRequestHeader(String, String, String, java.util.Map)} + * but with an explicit app name for multi-app routing. See + * {@link #encodeRequest(String, String, String, String, java.util.Map, byte[])} + * for app name semantics. + */ + public static byte[] encodeRequestHeader( + String appName, + String method, + String path, + String query, + java.util.Map headers) { + return encodeRequest( + appName, + Objects.requireNonNull(method, "method"), + Objects.requireNonNull(path, "path"), + query, + headers != null ? headers : java.util.Map.of(), + new byte[0]); + } + + /** + * Variant of {@link #dispatchStreaming(byte[], OutputStream)} that + * emits the wire-format response header via {@code headerConsumer} + * before the first body byte reaches + * {@code outputStream}. + * + *

This is the variant Spring {@link jakarta.servlet.http.HttpServletResponse} + * controllers want: the header callback fires while the response + * is still uncommitted, so the controller can call + * {@code resp.setStatus(...)} / {@code resp.setHeader(...)} from + * inside {@code headerConsumer.accept(byte[])}. + * + *

The {@code headerConsumer} is invoked exactly once + * on every code path (success or error); the bytes are a normal + * wire-format header (length-prefixed JSON). Use + * {@link #decodeResponse(byte[])} to extract status / headers / + * metadata from those bytes. + */ + public static native void dispatchStreamingWithHeader( + byte[] wireRequest, + Consumer headerConsumer, + OutputStream outputStream); + + /** + * Variant of {@link #dispatchFullStreaming(byte[], InputStream, OutputStream)} + * with the same header-callback contract as + * {@link #dispatchStreamingWithHeader}. Bidirectional streaming + * + ability to commit Spring response status/headers before the + * first body byte. + */ + public static native void dispatchFullStreamingWithHeader( + byte[] wireRequestHeader, + Consumer headerConsumer, + InputStream inputStream, + OutputStream outputStream); + + /** + * Encode a request into the binary wire format. + * + * @param method HTTP method (uppercase: {@code GET}, {@code POST}, ...) + * @param path URL path including any path parameters + * @param query raw query string (empty / {@code null} if none) + * @param headers request headers; lowercased keys are recommended + * @param body request body bytes (may be empty or {@code null}) + * @return length-prefixed wire bytes ready for {@link #dispatchBytes} */ - public static native String dispatch(String requestEnvelopeJson); + public static byte[] encodeRequest( + String method, + String path, + String query, + Map headers, + byte[] body) { + return encodeRequest(null, method, path, query, headers, body); + } + + /** + * Encode a request into the binary wire format with an explicit + * app name for multi-app routing. + * + *

When {@code appName} is {@code null}, empty, or blank, the + * request is routed to the default app + * (registered via the Rust {@code register_app} API). Otherwise + * the wire header carries {@code "app": ""} and the + * request is routed to the named app (registered via + * {@code register_app_named}). + * + * @param appName target app name (may be {@code null} for default) + * @param method HTTP method (uppercase: {@code GET}, {@code POST}, ...) + * @param path URL path including any path parameters + * @param query raw query string (empty / {@code null} if none) + * @param headers request headers; lowercased keys are recommended + * @param body request body bytes (may be empty or {@code null}) + * @return length-prefixed wire bytes ready for any dispatch* method + */ + public static byte[] encodeRequest( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body) { + try { + ObjectNode header = MAPPER.createObjectNode(); + header.put("v", WIRE_VERSION); + header.put("method", method); + header.put("path", path); + if (query != null && !query.isEmpty()) { + header.put("query", query); + } + if (headers != null && !headers.isEmpty()) { + ObjectNode hdrs = MAPPER.createObjectNode(); + for (Map.Entry e : headers.entrySet()) { + hdrs.put(e.getKey(), e.getValue()); + } + header.set("headers", hdrs); + } + if (appName != null && !appName.isBlank()) { + header.put("app", appName.trim()); + } + byte[] headerJson = MAPPER.writeValueAsBytes(header); + byte[] bodyBytes = body != null ? body : new byte[0]; + ByteBuffer buf = ByteBuffer + .allocate(4 + headerJson.length + bodyBytes.length) + .order(ByteOrder.BIG_ENDIAN); + buf.putInt(headerJson.length); + buf.put(headerJson); + buf.put(bodyBytes); + return buf.array(); + } catch (IOException e) { + throw new IllegalStateException("encodeRequest serialisation failed", e); + } + } + + /** + * Decode a wire-format response. + * + * @throws IllegalArgumentException if the wire bytes are malformed + */ + public static DecodedResponse decodeResponse(byte[] wire) { + if (wire == null || wire.length < 4) { + throw new IllegalArgumentException( + "wire response too short: " + + (wire == null ? "null" : wire.length + " bytes")); + } + ByteBuffer buf = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN); + int headerLen = buf.getInt(); + if (headerLen < 0 || (long) 4 + headerLen > wire.length) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + + " overflows response (" + wire.length + " bytes)"); + } + try { + JsonNode header = MAPPER.readTree( + new java.io.ByteArrayInputStream(wire, 4, headerLen)); + int status = header.path("status").asInt(500); + + Map headers = new LinkedHashMap<>(); + JsonNode hdrs = header.path("headers"); + if (hdrs.isObject()) { + Iterator> it = hdrs.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + JsonNode v = e.getValue(); + if (v.isArray()) { + List list = new ArrayList<>(v.size()); + for (JsonNode item : v) { + list.add(item.asText()); + } + headers.put(e.getKey(), list); + } else { + headers.put(e.getKey(), v.asText()); + } + } + } + + Map metadata = new LinkedHashMap<>(); + JsonNode mdNode = header.path("metadata"); + if (mdNode.isObject()) { + Iterator> it = mdNode.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + metadata.put(e.getKey(), e.getValue().asText()); + } + } + + // Hoisted validation errors (Vespera Validated 422 path). + // null when absent (any non-422 or non-Vespera 422). + List> validationErrors = null; + JsonNode veNode = header.path("validation_errors"); + if (veNode.isArray()) { + validationErrors = new ArrayList<>(veNode.size()); + for (JsonNode item : veNode) { + Map entry = new LinkedHashMap<>(); + Iterator> it = item.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + entry.put(e.getKey(), e.getValue().asText()); + } + validationErrors.add(entry); + } + } + + int bodyStart = 4 + headerLen; + byte[] body = Arrays.copyOfRange(wire, bodyStart, wire.length); + return new DecodedResponse(status, headers, metadata, body, validationErrors); + } catch (IOException e) { + throw new IllegalArgumentException("wire header JSON parse failed", e); + } + } // --- Internal: bundled native lib extraction --- @@ -48,7 +450,8 @@ private static void loadBundled(String libraryName) { String filename = mapLibraryName(os, libraryName); String resourcePath = "native/" + os + "-" + arch + "/" + filename; - try (InputStream in = VesperaBridge.class.getClassLoader().getResourceAsStream(resourcePath)) { + try (InputStream in = + VesperaBridge.class.getClassLoader().getResourceAsStream(resourcePath)) { if (in == null) { throw new UnsatisfiedLinkError("Not found in JAR: " + resourcePath); } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java new file mode 100644 index 0000000..050fd9d --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java @@ -0,0 +1,68 @@ +package com.devfive.vespera.bridge; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring Boot autoconfigure entry point for vespera-bridge. + * + *

Wires up zero-configuration defaults so a typical Spring Boot + * app needs only {@link VesperaBridge#init(String)} and the + * routes published in vespera's {@code openapi.json} are reachable + * at the same URLs. Every bean is gated by + * {@code @ConditionalOnMissingBean}, so any user-supplied custom + * bean automatically wins. + * + *

Customization recipes: + *

    + *
  • Header name: + * set {@code vespera.bridge.app-header}.
  • + *
  • Custom app selection: + * register a {@code @Bean AppNameResolver} — + * the default {@link HeaderAppNameResolver} is automatically + * disabled.
  • + *
  • Custom dispatch mode policy: + * register a {@code @Bean DispatchModeResolver} — + * the default + * {@link BidirectionalStreamingDispatchModeResolver} is + * automatically disabled.
  • + *
  • Completely BYO controller: + * set {@code vespera.bridge.controller-enabled=false} and + * provide your own {@code @RestController} that calls the + * {@link VesperaBridge} native methods directly.
  • + *
+ */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@EnableConfigurationProperties(VesperaBridgeProperties.class) +public class VesperaBridgeAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AppNameResolver vesperaBridgeAppNameResolver(VesperaBridgeProperties props) { + return new HeaderAppNameResolver(props.getAppHeader()); + } + + @Bean + @ConditionalOnMissingBean + public DispatchModeResolver vesperaBridgeDispatchModeResolver() { + return new BidirectionalStreamingDispatchModeResolver(); + } + + @Bean + @ConditionalOnProperty( + prefix = "vespera.bridge", + name = "controller-enabled", + havingValue = "true", + matchIfMissing = true) + @ConditionalOnMissingBean + public VesperaProxyController vesperaProxyController( + AppNameResolver appResolver, + DispatchModeResolver modeResolver) { + return new VesperaProxyController(appResolver, modeResolver); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java new file mode 100644 index 0000000..76cae4f --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java @@ -0,0 +1,60 @@ +package com.devfive.vespera.bridge; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Properties for the autoconfigured vespera-bridge defaults. + * Prefix: {@code vespera.bridge}. + * + *

Defaults are tuned for the "transparent wrapping" use-case: + * routes published in vespera's {@code openapi.json} are reachable + * at the same URLs through Spring, with no app-selection prefix or + * mode-selection prefix. Override individual properties to tweak + * the defaults, or register a custom + * {@link AppNameResolver} / {@link DispatchModeResolver} bean to + * fully replace the resolver logic. + * + *

{@code
+ * vespera:
+ *   bridge:
+ *     app-header: X-My-App        # override the default header name
+ *     controller-enabled: false   # disable our controller (BYO controller)
+ * }
+ */ +@ConfigurationProperties(prefix = "vespera.bridge") +public class VesperaBridgeProperties { + + /** + * HTTP request header inspected by the default + * {@link HeaderAppNameResolver} to pick the target app for + * multi-app routing. Default: {@code X-Vespera-App}. When the + * header is absent on a given request, that request is routed + * to the default app registered via {@code register_app}. + */ + private String appHeader = "X-Vespera-App"; + + /** + * When {@code true} (default), Spring Boot autoconfigures + * {@link VesperaProxyController} as a catch-all proxy. Set to + * {@code false} when you want to provide your own controller — + * the {@link VesperaBridge} native methods remain available + * for direct use either way. + */ + private boolean controllerEnabled = true; + + public String getAppHeader() { + return appHeader; + } + + public void setAppHeader(String appHeader) { + this.appHeader = appHeader; + } + + public boolean isControllerEnabled() { + return controllerEnabled; + } + + public void setControllerEnabled(boolean controllerEnabled) { + this.controllerEnabled = controllerEnabled; + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java index bcf8d9e..9f47215 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java @@ -1,94 +1,266 @@ package com.devfive.vespera.bridge; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import java.io.IOException; -import java.util.*; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; /** - * Catch-all proxy controller - auto-configured by Spring when - * {@code com.devfive.vespera.bridge} is on the classpath. + * Catch-all proxy controller — autoconfigured by + * {@link VesperaBridgeAutoConfiguration} when no user-supplied + * {@code VesperaProxyController} bean is present (gated by + * {@code vespera.bridge.controller-enabled}, default {@code true}). * - *

Forwards every HTTP request to Rust via JNI and returns the - * response verbatim (status, headers, body). + *

Endpoint contract: every URL published in + * vespera's generated {@code openapi.json} is reachable through + * Spring at the same URL. No path prefix is + * injected by this controller; routing transparently mirrors the + * Rust router. * - *

The user's Spring Boot app needs zero controller code: - *

{@code
- * @SpringBootApplication
- * public class MyApp {
- *     public static void main(String[] args) {
- *         VesperaBridge.init("my_rust_lib");
- *         SpringApplication.run(MyApp.class, args);
- *     }
- * }
- * }
+ *

Per request, the controller delegates two decisions to the + * configured strategies: + * + *

    + *
  1. {@link AppNameResolver#resolveAppName(HttpServletRequest)} + * — which named Rust app should receive this request + * ({@code null} → default app).
  2. + *
  3. {@link DispatchModeResolver#resolveMode(HttpServletRequest)} + * — which {@link DispatchMode} JNI path to use.
  4. + *
+ * + *

The autoconfigured defaults ({@link HeaderAppNameResolver} on + * {@code X-Vespera-App} + + * {@link BidirectionalStreamingDispatchModeResolver}) keep the + * proxy transparent for every payload size. Replace either bean + * to change the policy without subclassing this controller. */ @RestController public class VesperaProxyController { - private static final Logger log = LoggerFactory.getLogger(VesperaProxyController.class); - private final ObjectMapper mapper; + private static final Logger log = + LoggerFactory.getLogger(VesperaProxyController.class); + + private final AppNameResolver appResolver; + private final DispatchModeResolver modeResolver; + + public VesperaProxyController(AppNameResolver appResolver, + DispatchModeResolver modeResolver) { + this.appResolver = Objects.requireNonNull(appResolver, "appResolver"); + this.modeResolver = Objects.requireNonNull(modeResolver, "modeResolver"); + } + + @RequestMapping(value = "/**", consumes = MediaType.ALL_VALUE) + public Object proxy(HttpServletRequest request, + HttpServletResponse response) throws IOException { + + final String appName = appResolver.resolveAppName(request); + final DispatchMode mode = modeResolver.resolveMode(request); + final String method = request.getMethod(); + final String path = request.getRequestURI(); + final String query = Objects.toString(request.getQueryString(), ""); + final Map headers = collectHeaders(request); + + if (log.isDebugEnabled()) { + log.debug("-> Rust {} {} app={} mode={}", method, path, appName, mode); + } + + // For bidirectional streaming, pass the servlet InputStream + // straight through — DO NOT pre-read it. For every other + // mode, materialise the body bytes here (replaces Spring's + // @RequestBody, which we cannot use because it would consume + // the InputStream and leave the bidirectional path empty). + switch (mode) { + case SYNC: + return dispatchSync(appName, method, path, query, headers, + readBody(request)); + case ASYNC: + return dispatchAsyncFlow(appName, method, path, query, headers, + readBody(request)); + case STREAMING: + dispatchStreaming(request, response, appName, method, path, query, + headers, readBody(request)); + return null; + case BIDIRECTIONAL_STREAMING: + default: + dispatchBidirectional(request, response, appName, method, path, query, headers); + return null; + } + } + + /** + * Fully read the servlet request body into a byte array. Used + * by sync / async / response-streaming modes (the bidirectional + * mode forwards the InputStream as-is). + */ + private static byte[] readBody(HttpServletRequest request) throws IOException { + try (InputStream in = request.getInputStream()) { + return in.readAllBytes(); + } + } + + // ── Mode handlers ───────────────────────────────────────────────── + + /** Sync — full request body materialised, full response materialised. */ + private ResponseEntity dispatchSync( + String appName, String method, String path, String query, + Map headers, byte[] body) { + byte[] bodyBytes = body != null ? body : new byte[0]; + byte[] wireReq = VesperaBridge.encodeRequest( + appName, method, path, query, headers, bodyBytes); + byte[] wireResp = VesperaBridge.dispatchBytes(wireReq); + DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); + return buildResponseEntity(decoded); + } + + /** + * Async — request body materialised, response delivered via a + * {@link CompletableFuture}. Spring MVC adapts the future + * automatically to its servlet-async machinery. + */ + private CompletableFuture> dispatchAsyncFlow( + String appName, String method, String path, String query, + Map headers, byte[] body) { + byte[] bodyBytes = body != null ? body : new byte[0]; + byte[] wireReq = VesperaBridge.encodeRequest( + appName, method, path, query, headers, bodyBytes); + return VesperaBridge.dispatch(wireReq).thenApply(wireResp -> { + DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); + return buildResponseEntity(decoded); + }); + } - public VesperaProxyController(ObjectMapper mapper) { - this.mapper = mapper; + /** + * Response-only streaming — request body materialised, response + * streams chunk-by-chunk to the servlet output stream. Status + * and headers commit through the JNI header callback BEFORE the + * first body byte hits the wire. + */ + private void dispatchStreaming( + HttpServletRequest request, HttpServletResponse response, + String appName, String method, String path, String query, + Map headers, byte[] body) throws IOException { + byte[] bodyBytes = body != null ? body : new byte[0]; + byte[] wireReq = VesperaBridge.encodeRequest( + appName, method, path, query, headers, bodyBytes); + VesperaBridge.dispatchStreamingWithHeader( + wireReq, + headerBytes -> applyDecodedHeader(headerBytes, response), + response.getOutputStream()); + response.getOutputStream().flush(); } - @RequestMapping("/**") - public ResponseEntity proxy( - HttpServletRequest request, - @RequestBody(required = false) String body - ) throws IOException { + /** + * Bidirectional streaming — both request body (from + * {@code request.getInputStream()}) and response body (to + * {@code response.getOutputStream()}) flow chunk-by-chunk. + * 1 GiB ↔ 1 GiB transfers run in {@code O(chunk_size)} RAM on + * both Rust and JVM sides. + */ + private void dispatchBidirectional( + HttpServletRequest request, HttpServletResponse response, + String appName, String method, String path, String query, + Map headers) throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + appName, method, path, query, headers); + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> applyDecodedHeader(headerBytes, response), + request.getInputStream(), + response.getOutputStream()); + response.getOutputStream().flush(); + } - ObjectNode envelope = mapper.createObjectNode(); - envelope.put("method", request.getMethod()); - envelope.put("path", request.getRequestURI()); - envelope.put("query", Objects.toString(request.getQueryString(), "")); + // ── Helpers ────────────────────────────────────────────────────── - ObjectNode headers = mapper.createObjectNode(); + private static Map collectHeaders(HttpServletRequest request) { + Map headers = new LinkedHashMap<>(); Enumeration names = request.getHeaderNames(); while (names.hasMoreElements()) { String name = names.nextElement(); headers.put(name.toLowerCase(Locale.ROOT), request.getHeader(name)); } - envelope.set("headers", headers); - envelope.put("body", body != null ? body : ""); - - String reqJson = mapper.writeValueAsString(envelope); - log.debug("-> Rust {}", reqJson); - - String respJson = VesperaBridge.dispatch(reqJson); - log.debug("<- Rust {}", respJson); + return headers; + } - JsonNode resp = mapper.readTree(respJson); - int status = resp.path("status").asInt(500); - String respBody = resp.path("body").asText(""); + /** + * Apply a decoded wire header to {@link HttpServletResponse} — + * called from streaming dispatch callbacks BEFORE the first body + * byte is written, while the response is still uncommitted. + */ + private static void applyDecodedHeader(byte[] headerBytes, + HttpServletResponse response) { + DecodedResponse meta = VesperaBridge.decodeResponse(headerBytes); + response.setStatus(meta.status()); + for (Map.Entry entry : meta.headers().entrySet()) { + Object val = entry.getValue(); + if (val instanceof List list) { + for (Object v : list) { + response.addHeader(entry.getKey(), String.valueOf(v)); + } + } else if (val != null) { + response.setHeader(entry.getKey(), String.valueOf(val)); + } + } + } + /** + * Convert a fully-decoded sync/async wire response into a + * Spring {@link ResponseEntity}. Body is delivered as + * {@link String} for text-like Content-Types, + * {@code byte[]} otherwise. + */ + private static ResponseEntity buildResponseEntity(DecodedResponse decoded) { HttpHeaders httpHeaders = new HttpHeaders(); - JsonNode respHeaders = resp.path("headers"); - if (respHeaders.isObject()) { - Iterator> fields = respHeaders.fields(); - while (fields.hasNext()) { - Map.Entry entry = fields.next(); - JsonNode val = entry.getValue(); - if (val.isArray()) { - for (JsonNode v : val) { - httpHeaders.add(entry.getKey(), v.asText()); - } - } else { - httpHeaders.set(entry.getKey(), val.asText()); + for (Map.Entry entry : decoded.headers().entrySet()) { + Object val = entry.getValue(); + if (val instanceof List list) { + for (Object v : list) { + httpHeaders.add(entry.getKey(), String.valueOf(v)); } + } else if (val != null) { + httpHeaders.set(entry.getKey(), String.valueOf(val)); } } + HttpStatus status = HttpStatus.valueOf(decoded.status()); + String contentType = httpHeaders.getFirst(HttpHeaders.CONTENT_TYPE); + if (isTextContentType(contentType)) { + String bodyStr = new String(decoded.body(), StandardCharsets.UTF_8); + return new ResponseEntity<>(bodyStr, httpHeaders, status); + } + return new ResponseEntity<>(decoded.body(), httpHeaders, status); + } - return new ResponseEntity<>(respBody, httpHeaders, HttpStatus.valueOf(status)); + private static boolean isTextContentType(String ct) { + if (ct == null) return true; + String mime = ct.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + return mime.startsWith("text/") + || mime.equals("application/json") + || mime.endsWith("+json") + || mime.equals("application/xml") + || mime.endsWith("+xml") + || mime.equals("application/javascript") + || mime.equals("application/ecmascript") + || mime.equals("application/yaml") + || mime.equals("application/x-yaml") + || mime.equals("application/x-www-form-urlencoded") + || mime.equals("application/graphql"); } } diff --git a/libs/vespera-bridge/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/libs/vespera-bridge/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..3f0825c --- /dev/null +++ b/libs/vespera-bridge/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.devfive.vespera.bridge.VesperaBridgeAutoConfiguration diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java new file mode 100644 index 0000000..9a6569b --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java @@ -0,0 +1,238 @@ +package com.devfive.vespera.bridge; + +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-Java tests for the wire encode/decode helpers in + * {@link VesperaBridge}. These do NOT load the native library so they + * run in any JVM. The native {@code dispatchBytes} symbol is exercised + * end-to-end via the Rust integration tests and the {@code rust-jni-demo} + * Spring Boot smoke check. + */ +class VesperaWireTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void encodeRequest_layout_starts_with_big_endian_length_prefix() throws Exception { + byte[] wire = VesperaBridge.encodeRequest( + "GET", "/x", null, Map.of(), new byte[0]); + + assertTrue(wire.length >= 4, "wire must include length prefix"); + + ByteBuffer buf = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN); + int headerLen = buf.getInt(); + + assertEquals(wire.length, 4 + headerLen, + "no body → total length == 4 + headerLen"); + + byte[] headerJson = new byte[headerLen]; + System.arraycopy(wire, 4, headerJson, 0, headerLen); + JsonNode header = MAPPER.readTree(headerJson); + + assertEquals(1, header.path("v").asInt(), "version must be 1"); + assertEquals("GET", header.path("method").asText()); + assertEquals("/x", header.path("path").asText()); + } + + @Test + void encodeRequest_handles_non_ascii_path() throws Exception { + byte[] wire = VesperaBridge.encodeRequest( + "GET", "/한글/path", null, Map.of(), new byte[0]); + + int headerLen = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN).getInt(); + byte[] headerJson = new byte[headerLen]; + System.arraycopy(wire, 4, headerJson, 0, headerLen); + JsonNode header = MAPPER.readTree(headerJson); + assertEquals("/한글/path", header.path("path").asText()); + + // total wire length must equal length prefix + header bytes (no body) + assertEquals(wire.length, 4 + headerLen); + } + + @Test + void encodeRequest_includes_query_and_headers_when_present() throws Exception { + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + headers.put("x-trace-id", "abc-123"); + + byte[] wire = VesperaBridge.encodeRequest( + "POST", "/users", "page=1", headers, "{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + + int headerLen = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN).getInt(); + byte[] headerJson = new byte[headerLen]; + System.arraycopy(wire, 4, headerJson, 0, headerLen); + JsonNode h = MAPPER.readTree(headerJson); + assertEquals("page=1", h.path("query").asText()); + assertEquals("application/json", h.path("headers").path("content-type").asText()); + assertEquals("abc-123", h.path("headers").path("x-trace-id").asText()); + + // body bytes follow header verbatim + byte[] body = new byte[wire.length - 4 - headerLen]; + System.arraycopy(wire, 4 + headerLen, body, 0, body.length); + assertEquals("{\"x\":1}", new String(body, StandardCharsets.UTF_8)); + } + + /** Build a synthetic wire response (mimics what Rust would emit). */ + private static byte[] buildWireResponse(int status, String contentType, byte[] body) throws Exception { + return buildWireResponseWithExtras(status, contentType, body, null); + } + + /** Build a synthetic wire response with optional validation_errors header field. */ + private static byte[] buildWireResponseWithExtras( + int status, String contentType, byte[] body, + List> validationErrors) throws Exception { + Map headerMap = new LinkedHashMap<>(); + headerMap.put("v", 1); + headerMap.put("status", status); + Map headers = new LinkedHashMap<>(); + if (contentType != null) headers.put("content-type", contentType); + headerMap.put("headers", headers); + Map metadata = new LinkedHashMap<>(); + metadata.put("version", "0.1.51"); + headerMap.put("metadata", metadata); + if (validationErrors != null) { + headerMap.put("validation_errors", validationErrors); + } + + byte[] headerJson = MAPPER.writeValueAsBytes(headerMap); + ByteBuffer buf = ByteBuffer.allocate(4 + headerJson.length + body.length) + .order(ByteOrder.BIG_ENDIAN); + buf.putInt(headerJson.length); + buf.put(headerJson); + buf.put(body); + return buf.array(); + } + + @Test + void decodeResponse_parses_status_headers_and_body() throws Exception { + byte[] wire = buildWireResponse( + 418, "text/plain; charset=utf-8", "I'm a teapot".getBytes(StandardCharsets.UTF_8)); + + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + assertEquals(418, decoded.status()); + assertEquals("text/plain; charset=utf-8", decoded.headers().get("content-type")); + assertEquals("0.1.51", decoded.metadata().get("version")); + assertEquals("I'm a teapot", + new String(decoded.body(), StandardCharsets.UTF_8)); + } + + @Test + void decodeResponse_throws_on_short_input() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.decodeResponse(new byte[3])); + assertTrue(ex.getMessage().contains("too short"), ex.getMessage()); + } + + @Test + void decodeResponse_throws_when_header_len_overflows() { + // header_len = Integer.MAX_VALUE but only 4 bytes total + ByteBuffer buf = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN); + buf.putInt(Integer.MAX_VALUE); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.decodeResponse(buf.array())); + assertTrue(ex.getMessage().contains("overflows"), ex.getMessage()); + } + + @Test + void roundtrip_preserves_binary_body_byte_for_byte() throws Exception { + byte[] payload = new byte[1024]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + // Use the response-builder to simulate what dispatch would return, + // then decode it. + byte[] wire = buildWireResponse(200, "application/octet-stream", payload); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + + assertEquals(200, decoded.status()); + assertArrayEquals(payload, decoded.body(), + "binary body must round-trip byte-for-byte"); + } + + @Test + void decodeResponse_hoists_validation_errors_when_present() throws Exception { + List> errs = new ArrayList<>(); + Map e1 = new LinkedHashMap<>(); + e1.put("path", "username"); + e1.put("code", "length"); + e1.put("message", "too short"); + errs.add(e1); + Map e2 = new LinkedHashMap<>(); + e2.put("path", "email"); + e2.put("message", "not an email"); + errs.add(e2); + + byte[] wire = buildWireResponseWithExtras( + 422, + "application/json", + "{\"errors\":[...]}".getBytes(StandardCharsets.UTF_8), + errs); + + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + assertEquals(422, decoded.status()); + assertNotNull(decoded.validationErrors(), + "validationErrors should be populated when present in wire"); + assertEquals(2, decoded.validationErrors().size()); + assertEquals("username", decoded.validationErrors().get(0).get("path")); + assertEquals("length", decoded.validationErrors().get(0).get("code")); + assertEquals("too short", decoded.validationErrors().get(0).get("message")); + assertEquals("email", decoded.validationErrors().get(1).get("path")); + // Body still preserved alongside the hoisted header field: + assertArrayEquals( + "{\"errors\":[...]}".getBytes(StandardCharsets.UTF_8), + decoded.body(), + "body must be preserved verbatim even when errors are hoisted"); + } + + @Test + void decodeResponse_validation_errors_null_when_absent() throws Exception { + // Non-422 response should have null validationErrors + byte[] wire = buildWireResponse(200, "text/plain", "ok".getBytes(StandardCharsets.UTF_8)); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + assertEquals(200, decoded.status()); + assertNull(decoded.validationErrors(), + "non-422 response must not carry validationErrors"); + } + + @Test + void encode_decode_full_request_roundtrip_via_synthetic_response() throws Exception { + // Build a request, "echo" it back as the response body, decode. + byte[] reqBody = "hello".getBytes(StandardCharsets.UTF_8); + byte[] reqWire = VesperaBridge.encodeRequest( + "POST", "/echo", null, + Map.of("content-type", "text/plain"), + reqBody); + + // Extract body from request, mirror it back in a response wire. + int reqHeaderLen = ByteBuffer.wrap(reqWire).order(ByteOrder.BIG_ENDIAN).getInt(); + byte[] echoedBody = new byte[reqWire.length - 4 - reqHeaderLen]; + System.arraycopy(reqWire, 4 + reqHeaderLen, echoedBody, 0, echoedBody.length); + + byte[] respWire = buildWireResponse(200, "text/plain", echoedBody); + DecodedResponse decoded = VesperaBridge.decodeResponse(respWire); + + assertArrayEquals(reqBody, decoded.body()); + } +} diff --git a/openapi.json b/openapi.json deleted file mode 100644 index 4ed91e1..0000000 --- a/openapi.json +++ /dev/null @@ -1,4345 +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" - } - } - } - } - } - } - }, - "/complex-struct-body": { - "post": { - "operationId": "mod_file_with_complex_struct_body", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ComplexStructBody" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/complex-struct-body-with-rename": { - "post": { - "operationId": "mod_file_with_complex_struct_body_with_rename", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ComplexStructBodyWithRename" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/config": { - "get": { - "operationId": "get_config", - "tags": [ - "config" - ], - "description": "Get current config", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Config" - } - } - } - } - } - }, - "patch": { - "operationId": "update_config", - "tags": [ - "config" - ], - "description": "Update config", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateConfigRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Config" - } - } - } - } - } - } - }, - "/enums": { - "get": { - "operationId": "enum_endpoint", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Enum" - } - } - } - } - } - } - }, - "/enums/adjacently-tagged": { - "post": { - "operationId": "adjacently_tagged_endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdjacentlyTaggedResponse" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdjacentlyTaggedResponse" - } - } - } - } - } - } - }, - "/enums/enum2": { - "get": { - "operationId": "enum_endpoint2", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Enum2" - } - } - } - } - } - } - }, - "/enums/externally-tagged": { - "post": { - "operationId": "externally_tagged_endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternallyTaggedEvent" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternallyTaggedEvent" - } - } - } - } - } - } - }, - "/enums/internally-tagged": { - "post": { - "operationId": "internally_tagged_endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InternallyTaggedMessage" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InternallyTaggedMessage" - } - } - } - } - } - } - }, - "/enums/untagged": { - "post": { - "operationId": "untagged_endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UntaggedValue" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UntaggedValue" - } - } - } - } - } - } - }, - "/error": { - "get": { - "operationId": "error_endpoint", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/error/cookie-jar-logout": { - "post": { - "operationId": "cookie_jar_endpoint", - "tags": [ - "error" - ], - "description": "Logout endpoint that uses CookieJar to clear cookies", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/error/error-with-status": { - "get": { - "operationId": "error_endpoint_with_status_code", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/error/error-with-status2": { - "get": { - "operationId": "error_endpoint_with_status_code2", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, - "404": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, - "500": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - } - } - } - }, - "/error/error2": { - "get": { - "operationId": "error_endpoint2", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - } - } - } - }, - "/error/header-map": { - "get": { - "operationId": "header_map_endpoint", - "responses": { - "200": { - "description": "Successful response", - "headers": {}, - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - } - } - } - }, - "/error/header-map2": { - "get": { - "operationId": "header_map_endpoint2", - "responses": { - "200": { - "description": "Successful response", - "headers": {}, - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - } - } - } - }, - "/error/status-code/{id}": { - "delete": { - "operationId": "status_code_endpoint", - "tags": [ - "error" - ], - "description": "Delete endpoint that returns just a StatusCode", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "200": { - "description": "Successful response" - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/flatten": { - "post": { - "operationId": "list_users", - "tags": [ - "flatten" - ], - "description": "List users with pagination (demonstrates flatten for request/response)\n\nThe request accepts flattened pagination parameters (page, per_page)\nand returns a response with flattened metadata (total, has_more).", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserListRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserListResponse" - } - } - } - } - } - } - }, - "/flatten/search": { - "post": { - "operationId": "advanced_search", - "tags": [ - "flatten" - ], - "description": "Advanced search endpoint with multiple flatten fields", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdvancedSearchRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SearchResponse" - } - } - } - } - } - } - }, - "/foo/foo": { - "post": { - "operationId": "signup", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SignupRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SignupResponse" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/form": { - "post": { - "operationId": "subscribe", - "tags": [ - "form" - ], - "description": "Subscribe to newsletter via form submission", - "requestBody": { - "required": true, - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SubscribeRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubscribeResponse" - } - } - } - } - } - } - }, - "/form/contact": { - "post": { - "operationId": "contact", - "tags": [ - "form" - ], - "description": "Submit a contact form", - "requestBody": { - "required": true, - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ContactFormRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactFormResponse" - } - } - } - } - } - } - }, - "/form/upload": { - "post": { - "operationId": "upload", - "tags": [ - "form" - ], - "description": "Upload a file via raw multipart form data", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactFormResponse" - } - } - } - } - } - } - }, - "/generic/generic/{value}": { - "get": { - "operationId": "generic_endpoint", - "parameters": [ - { - "name": "value", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "value", - "name" - ] - } - } - } - } - } - } - }, - "/generic/generic2": { - "get": { - "operationId": "generic_endpoint2", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "$ref": "#/components/schemas/TestStruct" - } - }, - "required": [ - "value", - "name" - ] - } - } - } - } - } - } - }, - "/generic/generic3": { - "get": { - "operationId": "generic_endpoint3", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "$ref": "#/components/schemas/TestStruct" - }, - "value2": { - "type": "string" - } - }, - "required": [ - "value", - "name", - "value2" - ] - } - } - } - } - } - } - }, - "/generic/generic4": { - "get": { - "operationId": "generic_endpoint4", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "boolean" - }, - "value2": { - "type": "boolean" - } - }, - "required": [ - "value", - "name", - "value2" - ] - } - } - } - } - } - } - }, - "/generic/generic5": { - "get": { - "operationId": "generic_endpoint5", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContactResponse" - } - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - }, - "totalPage": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "items", - "page", - "size", - "totalPage" - ] - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/health": { - "get": { - "operationId": "health", - "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", - "format": "uint32" - } - }, - { - "name": "optional_age", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "uint32", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/memos": { - "post": { - "operationId": "create_memo", - "description": "Create a new memo", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMemoRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMemoRequest" - } - } - } - } - } - }, - "put": { - "operationId": "update_memo", - "description": "Update a memo", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMemoRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMemoRequest" - } - } - } - } - } - } - }, - "/memos/format": { - "get": { - "operationId": "get_memo_format", - "description": "Get memo response format", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/memos/{id}": { - "get": { - "operationId": "get_memo", - "description": "Get memo by id", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemoResponse" - } - } - } - } - } - } - }, - "/memos/{id}/detail": { - "get": { - "operationId": "get_memo_detail", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemoDetailResponse" - } - } - } - } - } - } - }, - "/memos/{id}/rel": { - "get": { - "operationId": "get_memo_rel", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MemoResponseRel" - } - } - } - } - } - } - }, - "/no-schema-query": { - "get": { - "operationId": "mod_file_with_no_schema_query", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/path/multi-path/{arg}/{var1}/{var2}": { - "get": { - "operationId": "mod_file_with_multi_path", - "parameters": [ - { - "name": "arg", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "var1", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "var2", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/path/multi-path/{var1}": { - "get": { - "operationId": "mod_file_with_test_struct", - "parameters": [ - { - "name": "var1", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestStruct" - } - } - } - } - } - } - }, - "/path/multi-path2/{arg}/{var1}/{var2}": { - "get": { - "operationId": "mod_file_with_multi_path_2", - "parameters": [ - { - "name": "arg", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "var1", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "var2", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/path/prefix/{var}": { - "get": { - "operationId": "prefix_variable", - "parameters": [ - { - "name": "var", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/struct-body": { - "post": { - "operationId": "mod_file_with_struct_body", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StructBody" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/struct-body-with-optional": { - "post": { - "operationId": "mod_file_with_struct_body_with_optional", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/struct-query": { - "get": { - "operationId": "mod_file_with_struct_query", - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/terms": { - "get": { - "operationId": "list_terms", - "tags": [ - "terms" - ], - "parameters": [ - { - "name": "termsType", - "in": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/TermsType" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TermsQuery" - } - } - } - } - } - } - }, - "/test-struct": { - "get": { - "operationId": "mod_file_with_test_struct", - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestStruct" - } - } - } - } - } - } - }, - "/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", - "format": "uint32" - } - }, - { - "name": "optional_age", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "uint32", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/typed-form": { - "get": { - "operationId": "list_file_uploads", - "tags": [ - "typed-form" - ], - "description": "List all file uploads", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileUploadResponse" - } - } - } - } - } - } - }, - "post": { - "operationId": "create_file_upload", - "tags": [ - "typed-form" - ], - "description": "Create a new file upload with multipart form data", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateFileUploadRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileUploadResponse" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/typed-form/{id}": { - "put": { - "operationId": "update_file_upload", - "tags": [ - "typed-form" - ], - "description": "Update a file upload with multipart form data", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UpdateFileUploadRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileUploadResponse" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - }, - "patch": { - "operationId": "patch_file_upload", - "tags": [ - "typed-form" - ], - "description": "Patch a file upload (partial update via schema_type! multipart)", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchFileUploadRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileUploadResponse" - } - } - } - }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/typed-header": { - "get": { - "operationId": "typed_header_jwt", - "parameters": [ - { - "name": "authorization", - "in": "header", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - }, - "post": { - "operationId": "typed_header", - "parameters": [ - { - "name": "user-agent", - "in": "header", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "content-type", - "in": "header", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/users": { - "get": { - "operationId": "get_users", - "description": "Get all users (returns public response without internal_score)", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserPublicResponse" - } - } - } - } - } - } - }, - "post": { - "operationId": "create_user", - "description": "Create a new user\nRequest body uses CreateUserRequest (generated from User with only name, email)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserPublicResponse" - } - } - } - } - } - } - }, - "/users/dto/{id}": { - "get": { - "operationId": "get_user_dto", - "description": "Get user DTO (demonstrates field rename feature)\nThe Rust struct uses user_id/display_name, but JSON uses id/name", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDTO" - } - } - } - } - } - } - }, - "/users/skip-response": { - "get": { - "operationId": "skip_response", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SkipResponse" - } - } - } - } - } - } - }, - "/users/summary": { - "get": { - "operationId": "get_users_summary", - "description": "Get user summaries (minimal fields for list views)", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserSummary" - } - } - } - } - } - } - } - }, - "/users/with-meta": { - "post": { - "operationId": "create_user_with_meta", - "description": "Create a new user with metadata (demonstrates `add` feature)\nRequest body uses CreateUserWithMeta (picks name/email, adds request_id/created_at)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserWithMeta" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserWithMeta" - } - } - } - } - } - } - }, - "/users/{id}": { - "get": { - "operationId": "get_user", - "description": "Get user by ID (full internal view)", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - } - }, - "/uuid-items": { - "get": { - "operationId": "list_uuid_items", - "tags": [ - "uuid_items" - ], - "description": "List all UUID items", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UuidItem" - } - } - } - } - } - } - }, - "post": { - "operationId": "create_uuid_item", - "tags": [ - "uuid_items" - ], - "description": "Create a new UUID item", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUuidItemRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UuidItem" - } - } - } - } - } - } - }, - "/validated/users": { - "post": { - "operationId": "create_validated_user", - "tags": [ - "validated" - ], - "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidatedUserRequest" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AdjacentlyTaggedResponse": { - "description": "Adjacently tagged enum - serializes as `{\"type\": \"...\", \"data\": ...}`\nExample: `{\"type\": \"Success\", \"data\": {\"items\": [\"a\", \"b\"]}}`", - "oneOf": [ - { - "type": "object", - "description": "Successful response with items", - "properties": { - "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "items" - ] - }, - "type": { - "type": "string", - "enum": [ - "Success" - ] - } - }, - "required": [ - "type", - "data" - ] - }, - { - "type": "object", - "description": "Error response with code and message", - "properties": { - "data": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "type": { - "type": "string", - "enum": [ - "Error" - ] - } - }, - "required": [ - "type", - "data" - ] - }, - { - "type": "object", - "description": "Empty response (unit variant)", - "properties": { - "type": { - "type": "string", - "enum": [ - "Empty" - ] - } - }, - "required": [ - "type" - ] - } - ], - "discriminator": { - "propertyName": "type" - } - }, - "AdvancedSearchRequest": { - "description": "Request combining multiple flattened structs", - "allOf": [ - { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query string" - } - }, - "required": [ - "query" - ] - }, - { - "$ref": "#/components/schemas/Pagination" - } - ] - }, - "ComplexStructBody": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "array": { - "type": "array", - "items": { - "type": "string" - } - }, - "map": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "nested_array": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nested_map": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nested_struct": { - "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "nested_struct_array": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nested_struct_array_map": { - "type": "array", - "items": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - } - }, - "nested_struct_map": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nested_struct_map_array": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" - } - } - }, - "required": [ - "name", - "age", - "nested_struct", - "array", - "map", - "nested_array", - "nested_map", - "nested_struct_array", - "nested_struct_map", - "nested_struct_array_map", - "nested_struct_map_array" - ] - }, - "ComplexStructBodyWithRename": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "array": { - "type": "array", - "items": { - "type": "string" - } - }, - "map": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "nestedArray": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nestedMap": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nestedStruct": { - "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "nestedStructArray": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nestedStructArrayMap": { - "type": "array", - "items": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - } - }, - "nestedStructMap": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/StructBodyWithOptional" - } - }, - "nestedStructMapArray": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "items": { - "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" - } - } - }, - "required": [ - "name", - "age", - "nestedStruct", - "array", - "map", - "nestedArray", - "nestedMap", - "nestedStructArray", - "nestedStructMap", - "nestedStructArrayMap", - "nestedStructMapArray" - ] - }, - "Config": { - "type": "object", - "properties": { - "delimiter": { - "type": "string", - "format": "char", - "nullable": true - }, - "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true - }, - "maxItems": { - "type": "integer", - "minimum": 0 - }, - "maxPrice": { - "type": "number", - "format": "decimal" - }, - "minPrice": { - "type": "number", - "format": "decimal" - }, - "priority": { - "type": "integer", - "format": "int32" - }, - "retryCount": { - "type": "integer", - "format": "uint8" - }, - "separator": { - "type": "string", - "format": "char" - }, - "taxRate": { - "type": "number", - "format": "decimal" - } - }, - "required": [ - "taxRate", - "minPrice", - "maxPrice", - "maxItems", - "retryCount", - "priority", - "separator" - ] - }, - "ConfigSchema": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64", - "default": 0 - }, - "temperature": { - "type": "number", - "format": "decimal", - "default": 0.7 - } - }, - "required": [ - "id", - "temperature" - ] - }, - "ContactFormRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "message": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string", - "nullable": true - } - }, - "required": [ - "name", - "email", - "message" - ] - }, - "ContactFormResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "ticketId": { - "type": "string" - } - }, - "required": [ - "success", - "ticketId" - ] - }, - "ContactResponse": { - "type": "object", - "properties": { - "adminReply": { - "type": "string", - "nullable": true - }, - "category": { - "type": "string", - "nullable": true - }, - "content": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "repliedAt": { - "type": "string", - "nullable": true - }, - "title": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "nullable": true - }, - "userId": { - "type": "integer", - "format": "int64" - } - }, - "required": [ - "id", - "userId", - "title", - "content", - "createdAt" - ] - }, - "CreateFileUploadRequest": { - "type": "object", - "properties": { - "document": { - "type": "string", - "format": "binary", - "nullable": true - }, - "name": { - "type": "string" - }, - "tags": { - "type": "string", - "nullable": true - }, - "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true - } - }, - "required": [ - "name" - ] - }, - "CreateMemoRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "title", - "content" - ] - }, - "CreateUserRequest": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "email": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "email" - ] - }, - "CreateUserWithMeta": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "createdAt": { - "type": "string", - "nullable": true - }, - "email": { - "type": "string" - }, - "name": { - "type": "string" - }, - "requestId": { - "type": "string" - } - }, - "required": [ - "name", - "email", - "requestId" - ] - }, - "CreateUuidItemRequest": { - "type": "object", - "description": "UUID item model for testing UUID format in OpenAPI", - "properties": { - "externalRef": { - "type": "string", - "format": "uuid", - "description": "External reference UUID", - "nullable": true - }, - "name": { - "type": "string", - "description": "Item name" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } - }, - "required": [ - "name", - "tags" - ] - }, - "Enum": { - "type": "string", - "enum": [ - "A", - "B", - "C" - ] - }, - "Enum2": { - "description": "Enum2 Description", - "oneOf": [ - { - "type": "object", - "properties": { - "A": { - "type": "string" - } - }, - "required": [ - "A" - ] - }, - { - "type": "object", - "properties": { - "B": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "age" - ] - } - }, - "required": [ - "B" - ] - }, - { - "type": "object", - "properties": { - "C": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "C" - ] - }, - { - "type": "object", - "properties": { - "D": { - "type": "boolean" - } - }, - "required": [ - "D" - ] - }, - { - "type": "object", - "properties": { - "E": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "E" - ] - }, - { - "type": "object", - "properties": { - "F": { - "type": "array", - "prefixItems": [ - { - "type": "string" - }, - { - "type": "integer", - "format": "int32" - } - ], - "minItems": 2, - "maxItems": 2 - } - }, - "required": [ - "F" - ] - }, - { - "type": "object", - "properties": { - "G": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "G" - ] - }, - { - "type": "object", - "properties": { - "H": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "H" - ] - }, - { - "type": "object", - "properties": { - "I": { - "$ref": "#/components/schemas/TestStruct" - } - }, - "required": [ - "I" - ] - }, - { - "type": "object", - "properties": { - "J": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TestStruct" - } - } - }, - "required": [ - "J" - ] - }, - { - "type": "object", - "properties": { - "K": { - "type": "array", - "prefixItems": [ - { - "$ref": "#/components/schemas/TestStruct" - }, - { - "$ref": "#/components/schemas/TestStruct" - } - ], - "minItems": 2, - "maxItems": 2 - } - }, - "required": [ - "K" - ] - }, - { - "type": "object", - "properties": { - "L": { - "type": "string", - "nullable": true - } - }, - "required": [ - "L" - ] - }, - { - "type": "object", - "properties": { - "M": { - "type": "array", - "items": { - "type": "string", - "nullable": true - } - } - }, - "required": [ - "M" - ] - }, - { - "type": "object", - "properties": { - "N": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "nullable": true, - "type": "string" - } - } - }, - "required": [ - "N" - ] - } - ] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "uint32" - }, - "error": { - "type": "string" - } - }, - "required": [ - "error", - "code" - ] - }, - "ErrorResponse2": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "uint32" - }, - "error": { - "type": "string" - } - }, - "required": [ - "error", - "code" - ] - }, - "ExternallyTaggedEvent": { - "description": "Externally tagged enum (default) - serializes as `{\"VariantName\": ...}`\nExample: `{\"Created\": {\"id\": 1, \"name\": \"test\"}}`\nThis is included for comparison with the other representations.", - "oneOf": [ - { - "type": "object", - "description": "Item was created", - "properties": { - "Created": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - } - }, - "required": [ - "Created" - ] - }, - { - "type": "object", - "description": "Item was updated", - "properties": { - "Updated": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id" - ] - } - }, - "required": [ - "Updated" - ] - }, - { - "type": "object", - "description": "Item was deleted", - "properties": { - "Deleted": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "Deleted" - ] - } - ] - }, - "FileUploadResponse": { - "type": "object", - "properties": { - "createdAt": { - "type": "string" - }, - "documentUrl": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isActive": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "thumbnailUrl": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "name", - "tags", - "isActive", - "createdAt" - ] - }, - "GenericStruct": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "object" - } - }, - "required": [ - "value", - "name" - ] - }, - "GenericStruct2": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "object" - }, - "value2": { - "type": "object" - } - }, - "required": [ - "value", - "name", - "value2" - ] - }, - "InSkipResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - }, - "InternallyTaggedMessage": { - "description": "Internally tagged enum - serializes as `{\"type\": \"...\", ...fields...}`\nExample: `{\"type\": \"Request\", \"id\": 1, \"method\": \"GET\"}`", - "oneOf": [ - { - "type": "object", - "description": "A request message", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "method": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "Request" - ] - } - }, - "required": [ - "type", - "id", - "method" - ] - }, - { - "type": "object", - "description": "A response message", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "result": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string", - "enum": [ - "Response" - ] - } - }, - "required": [ - "type", - "id" - ] - }, - { - "type": "object", - "description": "A notification (no payload)", - "properties": { - "type": { - "type": "string", - "enum": [ - "Notification" - ] - } - }, - "required": [ - "type" - ] - } - ], - "discriminator": { - "propertyName": "type" - } - }, - "MapQuery": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - }, - "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true - } - }, - "required": [ - "name", - "age" - ] - }, - "MemoCommentInMemoDetail": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int32" - }, - "memoId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "memoId", - "content" - ] - }, - "MemoCommentSchema": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "memo": { - "$ref": "#/components/schemas/MemoSchema" - }, - "memoId": { - "type": "integer", - "format": "int32" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "user": { - "$ref": "#/components/schemas/UserSchema" - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "memoId", - "content", - "createdAt", - "updatedAt", - "user", - "memo" - ] - }, - "MemoDetailResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "memoComments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemoCommentInMemoDetail" - } - }, - "status": { - "$ref": "#/components/schemas/MemoStatus" - }, - "title": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "user": { - "$ref": "#/components/schemas/UserSchema", - "nullable": true - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "title", - "content", - "status", - "createdAt", - "updatedAt", - "user", - "memoComments" - ] - }, - "MemoResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "status": { - "$ref": "#/components/schemas/MemoStatus" - }, - "title": { - "type": "string" - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "title", - "content", - "status", - "createdAt" - ] - }, - "MemoResponseComments": { - "type": "object", - "properties": { - "memoComments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemoResponseComments_MemoComments" - } - } - }, - "required": [ - "memoComments" - ] - }, - "MemoResponseComments_MemoComments": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "integer", - "format": "int32" - }, - "memoId": { - "type": "integer", - "format": "int32" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "memoId", - "content", - "createdAt", - "updatedAt" - ] - }, - "MemoResponseRel": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "status": { - "$ref": "#/components/schemas/MemoStatus" - }, - "title": { - "type": "string" - }, - "user": { - "$ref": "#/components/schemas/UserSchema" - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "title", - "content", - "status", - "createdAt", - "user" - ] - }, - "MemoSchema": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "status": { - "$ref": "#/components/schemas/MemoStatus" - }, - "title": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "user": { - "$ref": "#/components/schemas/UserSchema" - }, - "userId": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "userId", - "title", - "content", - "status", - "createdAt", - "updatedAt", - "user" - ] - }, - "MemoSnakeCase": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time", - "default": "1970-01-01T00:00:00+00:00" - }, - "id": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "user_id": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "id", - "user_id", - "created_at" - ] - }, - "MemoStatus": { - "type": "string", - "enum": [ - "draft", - "published", - "archived" - ] - }, - "PaginatedResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object" - } - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - }, - "totalPage": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "items", - "page", - "size", - "totalPage" - ] - }, - "Pagination": { - "type": "object", - "description": "Common pagination parameters that can be reused across requests", - "properties": { - "page": { - "type": "integer", - "format": "int32", - "description": "Page number (1-indexed)", - "default": 1 - }, - "perPage": { - "type": "integer", - "format": "int32", - "description": "Items per page", - "default": 20 - } - }, - "required": [ - "page", - "perPage" - ] - }, - "PatchFileUploadRequest": { - "type": "object", - "properties": { - "isActive": { - "type": "boolean", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "nullable": true - }, - "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true - } - } - }, - "ResponseMeta": { - "type": "object", - "description": "Common metadata for responses", - "properties": { - "hasMore": { - "type": "boolean", - "description": "Whether there are more pages" - }, - "total": { - "type": "integer", - "format": "int64", - "description": "Total number of items" - } - }, - "required": [ - "total", - "hasMore" - ] - }, - "SearchResponse": { - "description": "Response combining multiple flattened structs", - "allOf": [ - { - "type": "object", - "properties": { - "found": { - "type": "boolean", - "description": "Whether any results were found" - }, - "results": { - "type": "array", - "description": "Search results", - "items": { - "type": "string" - } - } - }, - "required": [ - "results", - "found" - ] - }, - { - "$ref": "#/components/schemas/ResponseMeta" - } - ] - }, - "SignupRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - }, - "required": [ - "email", - "password" - ] - }, - "SignupResponse": { - "type": "object", - "properties": { - "birthday": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "format": "int32" - }, - "job": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string", - "nullable": true - }, - "phoneNumber23": { - "type": "string" - } - }, - "required": [ - "id", - "email", - "name", - "phoneNumber23", - "createdAt" - ] - }, - "SingleRelSchema": { - "type": "object", - "properties": { - "single": { - "$ref": "#/components/schemas/SingleRelSchema_Single" - }, - "username": { - "type": "string", - "default": "" - } - }, - "required": [ - "username", - "single" - ] - }, - "SingleRelSchema_Single": { - "type": "object", - "properties": { - "username": { - "type": "string" - } - }, - "required": [ - "username" - ] - }, - "SingleSchema": { - "type": "object", - "properties": { - "singleRel": { - "$ref": "#/components/schemas/SingleSchema_SingleRel", - "nullable": true - }, - "username": { - "type": "string", - "default": "" - } - }, - "required": [ - "username" - ] - }, - "SingleSchema_SingleRel": { - "type": "object", - "properties": { - "username": { - "type": "string" - } - }, - "required": [ - "username" - ] - }, - "SkipResponse": { - "type": "object", - "properties": { - "email2": { - "type": "string", - "nullable": true - }, - "email4": { - "type": "string", - "nullable": true - }, - "email5": { - "type": "string", - "default": "" - }, - "email6": { - "type": "string", - "default": "default42" - }, - "in_skip": { - "$ref": "#/components/schemas/InSkipResponse" - }, - "in_skip2": { - "$ref": "#/components/schemas/InSkipResponse", - "nullable": true - }, - "in_skip3": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InSkipResponse" - } - }, - "in_skip4": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true - }, - "in_skip5": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true - }, - "in_skip6": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true - }, - "name": { - "type": "string" - }, - "num": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - "required": [ - "name", - "email5", - "email6", - "num", - "in_skip", - "in_skip3" - ] - }, - "StructBody": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "age" - ] - }, - "StructBodyWithOptional": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - } - } - }, - "StructQuery": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "age" - ] - }, - "SubscribeRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "email" - ] - }, - "SubscribeResponse": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isSubscribed": { - "type": "boolean" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "email", - "isSubscribed" - ] - }, - "TermsQuery": { - "type": "object", - "properties": { - "termsType": { - "$ref": "#/components/schemas/TermsType" - } - }, - "required": [ - "termsType" - ] - }, - "TermsType": { - "type": "string", - "enum": [ - "terms", - "privacy" - ] - }, - "TestStruct": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "age" - ] - }, - "ThirdMapQuery": { - "type": "object", - "properties": { - "age": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - }, - "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true - } - }, - "required": [ - "name", - "age" - ] - }, - "UntaggedValue": { - "description": "Untagged enum - serializes as just the variant data, no tag\nThe deserializer tries each variant in order until one matches.\nExample: `\"hello\"` or `42` or `{\"key\": \"value\"}`", - "oneOf": [ - { - "type": "string", - "description": "A string value" - }, - { - "type": "integer", - "format": "int64", - "description": "A numeric value" - }, - { - "type": "boolean", - "description": "A boolean value" - }, - { - "type": "object", - "description": "An object with key-value pairs", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "key", - "value" - ] - } - ] - }, - "UpdateConfigRequest": { - "type": "object", - "properties": { - "delimiter": { - "type": "string", - "format": "char", - "nullable": true - }, - "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true - }, - "maxItems": { - "type": "integer", - "minimum": 0, - "nullable": true - }, - "maxPrice": { - "type": "number", - "format": "decimal", - "nullable": true - }, - "minPrice": { - "type": "number", - "format": "decimal", - "nullable": true - }, - "priority": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "retryCount": { - "type": "integer", - "format": "uint8", - "nullable": true - }, - "separator": { - "type": "string", - "format": "char", - "nullable": true - }, - "taxRate": { - "type": "number", - "format": "decimal", - "nullable": true - } - } - }, - "UpdateFileUploadRequest": { - "type": "object", - "properties": { - "document": { - "type": "string", - "format": "binary", - "nullable": true - }, - "isActive": { - "type": "boolean", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "nullable": true - }, - "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true - } - } - }, - "UpdateMemoRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - } - }, - "required": [ - "title", - "content", - "id" - ] - }, - "User": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "uint32" - }, - "internal_score": { - "type": "integer", - "format": "int32", - "description": "Internal field - should be omitted in public APIs", - "nullable": true - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "email" - ] - }, - "UserDTO": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "id": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "UserInMemoDetail": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "email", - "name" - ] - }, - "UserItem": { - "type": "object", - "description": "Simple user representation", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "email" - ] - }, - "UserListRequest": { - "description": "Request with flattened pagination parameters\n\nThe pagination fields (page, per_page) are merged into this struct's JSON representation.", - "allOf": [ - { - "type": "object", - "properties": { - "filter": { - "type": "string", - "description": "Filter users by name (optional)", - "nullable": true - }, - "sort": { - "type": "string", - "description": "Sort order: \"asc\" or \"desc\"" - } - }, - "required": [ - "sort" - ] - }, - { - "$ref": "#/components/schemas/Pagination" - } - ] - }, - "UserListResponse": { - "description": "Paginated response with flattened metadata\n\nThe response meta fields (total, has_more) are merged into this struct's JSON representation.", - "allOf": [ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "List of users", - "items": { - "$ref": "#/components/schemas/UserItem" - } - } - }, - "required": [ - "data" - ] - }, - { - "$ref": "#/components/schemas/ResponseMeta" - } - ] - }, - "UserPublicResponse": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "email" - ] - }, - "UserSchema": { - "type": "object", - "description": "User model", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Created at", - "default": "1970-01-01T00:00:00+00:00" - }, - "email": { - "type": "string", - "description": "User email" - }, - "id": { - "type": "integer", - "format": "int32", - "description": "User ID", - "default": 0 - }, - "name": { - "type": "string", - "description": "User name" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "Updated at", - "default": "1970-01-01T00:00:00+00:00" - } - }, - "required": [ - "id", - "email", - "name", - "createdAt", - "updatedAt" - ] - }, - "UserSummary": { - "type": "object", - "description": "Full user model with all fields", - "properties": { - "id": { - "type": "integer", - "format": "uint32" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "UuidItem": { - "type": "object", - "description": "UUID item model for testing UUID format in OpenAPI", - "properties": { - "externalRef": { - "type": "string", - "format": "uuid", - "description": "External reference UUID", - "nullable": true - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Item ID", - "default": "00000000-0000-0000-0000-000000000000" - }, - "name": { - "type": "string", - "description": "Item name" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } - }, - "required": [ - "id", - "name", - "tags" - ] - }, - "UuidItemSchema": { - "type": "object", - "description": "UUID item model for testing UUID format in OpenAPI", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Created at", - "default": "1970-01-01T00:00:00+00:00" - }, - "externalRef": { - "type": "string", - "format": "uuid", - "description": "External reference UUID", - "nullable": true - }, - "id": { - "type": "string", - "format": "uuid", - "description": "Item ID", - "default": "00000000-0000-0000-0000-000000000000" - }, - "name": { - "type": "string", - "description": "Item name" - } - }, - "required": [ - "id", - "name", - "createdAt" - ] - }, - "ValidatedUserRequest": { - "type": "object", - "description": "Validated request body for `POST /validated/users`.", - "properties": { - "age": { - "type": "integer", - "format": "uint32", - "description": "Display age (0–150).", - "minimum": 0, - "maximum": 150 - }, - "email": { - "type": "string", - "format": "email", - "description": "Primary contact email — validated at the format level." - }, - "tags": { - "type": "array", - "description": "Arbitrary tag list, 1–5 items.", - "items": { - "type": "string" - }, - "minItems": 1, - "maxItems": 5 - }, - "username": { - "type": "string", - "description": "User-chosen handle.", - "minLength": 3, - "maxLength": 32, - "pattern": "^[a-z0-9_]+$" - } - }, - "required": [ - "username", - "email", - "age", - "tags" - ] - } - } - }, - "tags": [ - { - "name": "config" - }, - { - "name": "error" - }, - { - "name": "flatten" - }, - { - "name": "form" - }, - { - "name": "hello" - }, - { - "name": "terms" - }, - { - "name": "typed-form" - }, - { - "name": "uuid_items" - }, - { - "name": "validated" - }, - { - "name": "third" - } - ] -} \ No newline at end of file From 2343640ff0842e93cfc49f41424ca2d7bba4776c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 25 May 2026 16:37:01 +0900 Subject: [PATCH 4/8] Impl serve --- crates/vespera/Cargo.toml | 7 +++-- crates/vespera/src/lib.rs | 4 +++ crates/vespera/src/serve.rs | 43 ++++++++++++++++++++++++++++++ examples/rust-jni-demo/src/main.rs | 13 ++++----- 4 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 crates/vespera/src/serve.rs diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 406343c..c881646 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -19,7 +19,7 @@ default = [ "axum-extra/cookie", "validation", ] -cron = ["dep:tokio-cron-scheduler", "dep:tokio"] +cron = ["dep:tokio-cron-scheduler"] inprocess = ["dep:vespera_inprocess"] jni = ["inprocess", "dep:vespera_jni"] # Runtime validation: `#[derive(Schema)]` additionally emits @@ -38,7 +38,10 @@ serde_json = "1" tower-layer = "0.3" tower-service = "0.3" tokio-cron-scheduler = { version = "0.15", optional = true } -tokio = { version = "1", features = ["rt"], optional = true } +# Used by the `Serve` extension trait to bind a TcpListener and drive +# axum::serve. Default-on because virtually every axum user already +# has tokio in their dependency graph. +tokio = { version = "1", features = ["net", "rt"] } vespera_inprocess = { workspace = true, optional = true } vespera_jni = { workspace = true, optional = true } # Hidden behind `validation` feature; re-exported via the private diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index ff5d3a7..f233d09 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -143,6 +143,10 @@ pub use validated::{ValidatePayload, Validated}; #[cfg(feature = "inprocess")] pub use vespera_inprocess as inprocess; +/// One-liner `Router::serve(addr)` extension — see [`serve::Serve`]. +pub mod serve; +pub use serve::Serve; + /// JNI bridge — call Rust axum apps from Java. #[cfg(feature = "jni")] pub use vespera_jni as jni; diff --git a/crates/vespera/src/serve.rs b/crates/vespera/src/serve.rs new file mode 100644 index 0000000..59c9642 --- /dev/null +++ b/crates/vespera/src/serve.rs @@ -0,0 +1,43 @@ +//! [`Serve`] — extension trait that lets you start an `axum::Router` +//! with a one-liner. +//! +//! ```no_run +//! use vespera::{vespera, Serve}; +//! +//! #[tokio::main] +//! async fn main() -> std::io::Result<()> { +//! vespera!(title = "My API").serve("0.0.0.0:3000").await +//! } +//! ``` +//! +//! Equivalent to: +//! +//! ```ignore +//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; +//! axum::serve(listener, app).await?; +//! ``` + +use std::io; + +use tokio::net::ToSocketAddrs; + +/// Extension trait that adds a one-liner [`Serve::serve`] method to +/// any [`axum::Router`]. +pub trait Serve { + /// Bind a TCP listener to `addr` and drive [`axum::serve`] until + /// the listener stops. + /// + /// `addr` accepts anything that implements + /// [`tokio::net::ToSocketAddrs`] — strings (`"0.0.0.0:3000"`), + /// tuples (`("127.0.0.1", 8080)`), [`std::net::SocketAddr`], … + fn serve(self, addr: impl ToSocketAddrs) + -> impl std::future::Future>; +} + +impl Serve for axum::Router { + async fn serve(self, addr: impl ToSocketAddrs) -> io::Result<()> { + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, self).await?; + Ok(()) + } +} diff --git a/examples/rust-jni-demo/src/main.rs b/examples/rust-jni-demo/src/main.rs index 728712f..52da6e9 100644 --- a/examples/rust-jni-demo/src/main.rs +++ b/examples/rust-jni-demo/src/main.rs @@ -7,22 +7,19 @@ //! ```text //! cargo run -p rust-jni-demo //! # → http://localhost:3000/health +//! # → http://localhost:3000/echo //! # → http://localhost:3000/documents/validate //! ``` use rust_jni_demo::create_app; -use vespera::axum; +use vespera::Serve; #[tokio::main] -async fn main() { - let app = create_app(); - - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000)); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - +async fn main() -> std::io::Result<()> { println!("Server running on http://localhost:3000"); println!(" GET /health"); + println!(" POST /echo"); println!(" POST /documents/validate"); - axum::serve(listener, app).await.unwrap(); + create_app().serve("0.0.0.0:3000").await } From 0709074aa29b0025372dc9d810dfb5e61442634b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 25 May 2026 16:59:10 +0900 Subject: [PATCH 5/8] Update docs --- AGENTS.md | 87 +++++++++++++++++++++++++++++++++++ CLAUDE.md | 76 ------------------------------- README.md | 78 ++++++++++++++++++++++++++++---- SKILL.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 275 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1963271..f3cf0a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,29 @@ **Generated:** 2026-03-21 **Branch:** main +> This file is the **single source of truth** for repository conventions. +> `CLAUDE.md` is intentionally a one-line redirect (`@AGENTS.md`) — never duplicate +> guidance into CLAUDE.md. + ## OVERVIEW Vespera is a fully automated OpenAPI 3.1 engine for Axum - delivers FastAPI-like DX to Rust. Zero-config route discovery via compile-time macro scanning. Also provides in-process dispatch (`vespera_inprocess` crate) and JNI integration (`vespera_jni` crate) for embedding Rust axum apps inside Java/Spring applications without HTTP overhead. +### Headline Capabilities (2026) + +| Capability | Where | Notes | +|---|---|---| +| **`#[derive(Schema)]` → OpenAPI 3.1** | `vespera_macro::Schema` | Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations | +| **`Validated` extractor + auto-`422`** | `vespera::Validated`, `crates/vespera/src/validated.rs` | Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is **`422 Unprocessable Entity`** with `{"errors":[{"path","message"}]}` JSON envelope | +| **`schema_type! { ... }`** | `vespera_macro::schema_type` | Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) — first-class SeaORM relation support | +| **One-liner `.serve(addr)`** | `vespera::Serve` (`crates/vespera/src/serve.rs`) | Extension trait on `axum::Router` — `create_app().serve("0.0.0.0:3000").await` replaces 3 lines of `TcpListener::bind` + `axum::serve` boilerplate | +| **Binary wire format (JNI)** | `vespera_inprocess` | `[u32 BE len | UTF-8 JSON header | raw body]` — multipart / PDFs / images travel as raw bytes; **`422` validation errors hoisted** into the wire header as `"validation_errors": [...]` so Java decoders never special-case error shapes | +| **Multi-app routing (JNI/FFI)** | `vespera::jni_apps! { "_default" => app, "admin" => admin_app }` | Wire header carries optional `"app"` field; Java side picks per request via `X-Vespera-App` header (configurable via `AppNameResolver`) | +| **Zero-config Spring autoconfigure** | `libs/vespera-bridge/.../VesperaBridgeAutoConfiguration` | `VesperaProxyController` + `AppNameResolver` + `DispatchModeResolver` beans auto-registered; replace any of them via `@ConditionalOnMissingBean` | +| **Cron jobs** | `#[vespera::cron("...")]` | Auto-discovered like routes; runs via `tokio-cron-scheduler` | + ## STRUCTURE ``` @@ -248,6 +265,76 @@ Generate request/response types from existing structs with powerful transformati | `rename_all` | Serde rename strategy | | `ignore` | Skip Schema derive | +## REPOSITORY SHAPE + +Vespera is a **hybrid monorepo** with two workspaces co-located at the repo root: + +| Workspace | Manager | Members | Purpose | +|---|---|---|---| +| Cargo (`Cargo.toml`) | cargo | `crates/*`, `examples/*` (excluding `examples/java-jni-demo`) | OpenAPI engine, proc-macros, JNI bridge | +| Bun (`package.json`) | bun | `apps/*` | Marketing/docs site + admin panel (Next.js) | + +`bun run ...` operates on the Node side; `cargo ...` on the Rust side. Many root +scripts deliberately cross the boundary — e.g., `prelint` runs `cargo +clippy/fmt/check` **before** oxlint touches JS. + +### Common Commands + +```bash +# --- Rust side --- +cargo build # Build all crates +cargo test --workspace # All Rust tests +cargo test -p vespera_macro # One crate +cargo test --test -- # Single integration test +cargo tarpaulin --out stdout # Coverage (via `bun run posttest`) + +# --- Lint / format (order matters — `prelint` runs Rust FIRST) --- +bun run lint # oxlint (after `cargo clippy + fmt --check + check`) +bun run lint:fix # oxlint --fix (after `cargo clippy --fix && cargo fmt`) + +# --- Front-end workspace --- +bun run dev # `dev` in every apps/* +bun run build # apps/front + apps/admin +cd apps/front && bun dev # Single-app dev (preferred per devfive-frontend) + +# --- Tests (Bun side) --- +bun test # Root runs bun test + tarpaulin (posttest hook) + +# --- Release tooling --- +bun run changepacks # @changepacks/cli version bumps +``` + +> **`prelint` gotcha:** any Rust warning fails the JS lint. Run `bun run +> lint:fix` to auto-resolve both sides. + +### Frontend (`apps/front`) + +Next.js 16 App Router + React 19 + `@devup-ui/react` (build-time CSS-in-JS). +Theme tokens live in `apps/front/devup.json` and use `$token` syntax in JSX +props only. + +- `apps/front/src/app/` contains **only** `layout.tsx` + `page.tsx` — all other + components live in `src/components/` (per devfive-frontend conventions). +- Styling uses devup-ui shorthand props (`bg`, `p`, `w`, `_hover`, + `[mobile,null,pc]` responsive arrays). Never `style={{...}}` or Tailwind. + +### Where Tests Live + +| Concern | Location | +|---|---| +| Macro integration tests | `crates/vespera_macro/tests/` (+ `insta` snapshots) | +| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | +| Core unit tests | `crates/vespera_core/src/**` inline `#[cfg(test)]` | +| JNI end-to-end | `examples/rust-jni-demo` (Rust + Java + Gradle) | +| Front tests | `apps/front/src/__tests__/` (`bun test` + `bun-test-env-dom`) | + +`insta` snapshots — run `cargo insta review` to accept drifts. + +### Pre-Commit (Husky) + +`bun run prepare` installs husky; commits trigger `.husky/` hooks (typically +`lint`). Never bypass with `--no-verify`; fix the underlying finding. + ## CONVENTIONS - **Rust 2024 edition** across all crates diff --git a/CLAUDE.md b/CLAUDE.md index 03ce067..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,77 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - @AGENTS.md - -## Repository Shape - -Vespera is a **hybrid monorepo** with two workspaces living side-by-side at the repo root: - -| Workspace | Manager | Members | Purpose | -|-----------|---------|---------|---------| -| Cargo (`Cargo.toml`) | cargo | `crates/*`, `examples/*` (excluding `examples/java-jni-demo`) | OpenAPI engine, proc-macros, JNI bridge | -| Bun (`package.json`) | bun | `apps/*` | Marketing/docs site + admin panel (Next.js) | - -Both live at the root — `bun run ...` operates on the Node side; `cargo ...` on the Rust side. Many root scripts deliberately cross the boundary (e.g., `prelint` runs `cargo clippy/fmt/check` **before** oxlint runs on JS). See `AGENTS.md` for crate-level detail. - -## Common Commands (Root) - -```bash -# Rust side -cargo build # Build all crates -cargo test --workspace # All Rust tests -cargo test -p vespera_macro # One crate -cargo test --test -- # Single integration test -cargo tarpaulin --out stdout # Coverage (run via `bun run posttest`) - -# Lint / format (order matters — `prelint` hook runs Rust FIRST) -bun run lint # oxlint over JS/TS (runs after prelint → cargo clippy+fmt+check) -bun run lint:fix # oxlint --fix (prelint:fix runs cargo clippy --fix + fmt first) - -# Front-end workspace -bun run dev # Runs `dev` in every apps/* (Next.js dev servers) -bun run build # Builds apps/front and apps/admin -cd apps/front && bun dev # Single-app dev (preferred over -F flag, per devfive-frontend skill) - -# Tests (Bun side) -bun test # Root runs bun test + tarpaulin via posttest hook - -# Release tooling -bun run changepacks # Version bumps via @changepacks/cli -``` - -> **`prelint` gotcha:** `bun run lint` triggers `cargo clippy -- -D warnings && cargo fmt --check && cargo check` first. Any Rust warning fails the JS lint. Run `bun run lint:fix` (which chains `cargo clippy --fix && cargo fmt`) to auto-resolve both sides. - -## Frontend (`apps/front`) - -Next.js 16 App Router + React 19 + @devup-ui/react (build-time CSS-in-JS). Theme tokens live in `apps/front/devup.json` and use the `$token` syntax in JSX props only. - -- `apps/front/src/app/` contains **only** `layout.tsx` and `page.tsx` — all other components belong in `src/components/` (per devfive-frontend conventions). -- `src/api.ts` is generated/edited via `@devup-api/fetch`; it currently contains a placeholder `/users/users` call that fails typecheck — a known scaffolding leftover, not a regression. -- Styling: use devup-ui shorthand props (`bg`, `p`, `w`, `_hover`, `[mobile,null,pc]` responsive arrays). Never `style={{...}}` or Tailwind. See `~/.claude/skills/devup-ui/SKILL.md` via the `/devup-ui` skill. - -## Rust × Java Boundary - -The JNI integration (`crates/vespera_jni` → `libs/vespera-bridge/` Java lib) is load-bearing for `examples/rust-jni-demo`. When touching: - -- `vespera_inprocess` owns transport-agnostic dispatch (no JNI deps). -- `vespera_jni` is a thin layer depending on `vespera_inprocess` + `jni` + `tokio/rt-multi-thread`. -- User code depends on `vespera` only, with `features = ["jni"]` — never `vespera_jni` directly. Breaking this invariant is an AGENTS.md-listed anti-pattern. - -The Java package `com.devfive.vespera.bridge` is **fixed** because the JNI symbol name is derived from it. Renaming it breaks the native load. - -## Pre-Commit (Husky) - -`bun run prepare` installs husky. Commits trigger whatever lives in `.husky/` — typically a `lint` pass. Never bypass with `--no-verify`; fix the underlying Rust or oxlint finding. - -## Where Tests Live - -| Concern | Location | -|---------|----------| -| Macro integration tests | `crates/vespera_macro/tests/` (+ `insta` snapshots) | -| Core unit tests | `crates/vespera_core/src/**` inline `#[cfg(test)]` | -| JNI end-to-end | `examples/rust-jni-demo` (Rust + Java + Gradle) | -| Front tests | `apps/front/src/__tests__/` (bun test + bun-test-env-dom) | - -Snapshot tests use `insta` — run `cargo insta review` to accept drifts. diff --git a/README.md b/README.md index 63646f8..f109b6c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ let app = vespera!(openapi = "openapi.json", docs_url = "/docs"); |---------|---------|-----------------| | Route registration | Automatic (file-based) | Manual `Router::new().route(...)` | | OpenAPI spec | Generated at compile time | Hand-written or runtime generation | -| Schema extraction | From Rust types | Manual JSON Schema | +| Schema extraction | From Rust types (`#[derive(Schema)]`) | Manual JSON Schema | +| **Request validation** | **`Validated` extractor → auto `422`** | Manual checks in every handler | +| **Server startup** | **`.serve("0.0.0.0:3000")` one-liner** | `TcpListener::bind` + `axum::serve` | | Swagger UI | Built-in | Separate setup | | Type safety | Compile-time verified | Runtime errors | @@ -73,22 +75,26 @@ pub async fn create_user(Json(user): Json) -> Json { **`src/main.rs`**: ```rust -use vespera::vespera; +use vespera::{vespera, Serve}; #[tokio::main] -async fn main() { - let app = vespera!( +async fn main() -> std::io::Result<()> { + println!("Swagger UI: http://localhost:3000/docs"); + vespera!( openapi = "openapi.json", title = "My API", docs_url = "/docs" - ); - - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - println!("Swagger UI: http://localhost:3000/docs"); - axum::serve(listener, app).await.unwrap(); + ) + .serve("0.0.0.0:3000") + .await } ``` +> `.serve(addr)` is a vespera-provided extension trait on `axum::Router` — it +> replaces the usual `TcpListener::bind` + `axum::serve(...)` dance with a +> single chained call. `addr` accepts anything `tokio::net::ToSocketAddrs` +> takes (strings, tuples, `SocketAddr`). + ### 4. Run ```bash @@ -151,6 +157,60 @@ pub struct CreateUserRequest { } ``` +### Request Validation (`Validated` → `422`) + +`Validated` wraps any axum extractor (`Json`, `Form`, `Query`, `Path`) and +runs the inner type's [`garde::Validate`] impl **before** the handler is +called. Validation failures are converted to **`422 Unprocessable Entity`** +with a canonical JSON envelope — no manual error mapping per handler. + +```rust +use vespera::{Validated, Schema, axum::Json}; +use garde::Validate; + +#[derive(serde::Deserialize, Schema, Validate)] +pub struct CreateUser { + #[garde(length(min = 3, max = 32))] + pub username: String, + #[garde(email)] + pub email: String, + #[garde(range(min = 18, max = 120))] + pub age: u8, +} + +#[vespera::route(post, tags = ["users"])] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + // `req` has already passed garde validation. + Json("ok") +} +``` + +**Failure response (`HTTP/1.1 422 Unprocessable Entity`):** + +```json +{ + "errors": [ + { "path": "username", "message": "length is lower than 3" }, + { "path": "email", "message": "not a valid email" } + ] +} +``` + +Works with every common extractor — same `422` envelope on the wire: + +| Extractor | Validates | +|---|---| +| `Validated>` | JSON body | +| `Validated>` | URL-encoded form body | +| `Validated>` | URL query parameters | +| `Validated>` | Path parameters | + +Under JNI, the same `422` body is **hoisted** into the binary wire header as +`"validation_errors": [...]` — Java decoders consume validation errors +without parsing the body. See [`crates/vespera/tests/jni_validation.rs`](./crates/vespera/tests/jni_validation.rs). + ### Supported Extractors | Extractor | OpenAPI Mapping | diff --git a/SKILL.md b/SKILL.md index 4a07865..4bfc7fa 100644 --- a/SKILL.md +++ b/SKILL.md @@ -10,24 +10,130 @@ Vespera = FastAPI DX for Rust. Zero-config OpenAPI 3.1 generation via compile-ti ## Quick Start ```rust -// 1. Main entry - vespera! macro handles everything -let app = vespera!( - openapi = "openapi.json", // writes file at compile time - title = "My API", - version = "1.0.0", - docs_url = "/docs", // Swagger UI - redoc_url = "/redoc" // ReDoc alternative -); +use vespera::{vespera, Serve, Schema, Validated, axum::Json}; +use axum::extract::Path; +use serde::{Deserialize, Serialize}; +use garde::Validate; + +// 1. Custom types — derive Schema for OpenAPI inclusion. +// Add `garde::Validate` to opt into 422 validation. +#[derive(Serialize, Deserialize, Schema, Validate)] +pub struct CreateUser { + #[garde(length(min = 3, max = 32))] + pub name: String, + #[garde(email)] + pub email: String, +} -// 2. Route handlers - MUST be pub async fn +// 2. Route handlers — MUST be `pub async fn`. #[vespera::route(get, path = "/{id}", tags = ["users"])] -pub async fn get_user(Path(id): Path) -> Json { ... } +pub async fn get_user(Path(id): Path) -> Json { /* ... */ } + +// 3. Validated extractor → automatic 422 on bad input. +#[vespera::route(post, tags = ["users"])] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + // `req` already passed validation. Failures never reach here. + Json("ok") +} + +// 4. Main — one-liner `.serve()` from the `Serve` extension trait. +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!( + openapi = "openapi.json", // writes file at compile time + title = "My API", + version = "1.0.0", + docs_url = "/docs", // Swagger UI + redoc_url = "/redoc" // ReDoc alternative + ) + .serve("0.0.0.0:3000") + .await +} +``` + +--- + +## Request Validation (`Validated` → `422`) + +Wrap any extractor with `Validated<...>` to enforce `garde::Validate` **before** +the handler runs. Vespera converts validation failures into a canonical +`422 Unprocessable Entity` response — no per-handler error mapping. + +```rust +use vespera::{Validated, axum::Json}; + +#[vespera::route(post)] +pub async fn create( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + Json("ok") +} +``` -// 3. Custom types - derive Schema for OpenAPI inclusion -#[derive(Serialize, Deserialize, vespera::Schema)] -pub struct User { id: u32, name: String } +**Response on validation failure (status `422`, content-type `application/json`):** + +```json +{ + "errors": [ + { "path": "name", "message": "length is lower than 3" }, + { "path": "email", "message": "not a valid email" } + ] +} +``` + +### Supported wrappers + +| Wrapper | Validates | +|---|---| +| `Validated>` | JSON body | +| `Validated>` | URL-encoded form body | +| `Validated>` | URL query string | +| `Validated>` | Path parameters | + +### Requirements + +- `T` (or the inner type of `Json`, `Form`, …) must implement + `garde::Validate`. +- Derive `garde::Validate` and annotate fields with `#[garde(...)]` rules + (`length`, `email`, `range`, `pattern`, custom, …). +- Vespera's `#[derive(Schema)]` continues to drive the OpenAPI spec — the two + derives compose cleanly on the same struct. + +### JNI / Binary wire integration + +When a `Validated` rejection crosses the JNI boundary, the JSON envelope +(`{"errors":[...]}`) is **hoisted** into the binary wire-format header as +`"validation_errors": [...]`. Java decoders inspect the field directly +without re-parsing the body. See +`crates/vespera/tests/jni_validation.rs` for the pinned contract. + +--- + +## One-Liner Server Startup (`Serve`) + +`vespera::Serve` is an extension trait on `axum::Router`. It replaces the +standard `TcpListener::bind` + `axum::serve(...)` dance with a single chained +call: + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!(title = "My API") + .serve("0.0.0.0:3000") + .await +} ``` +- `addr` accepts anything `tokio::net::ToSocketAddrs` accepts — strings + (`"0.0.0.0:3000"`), tuples (`("127.0.0.1", 8080)`), `SocketAddr`, etc. +- Works on **any** `axum::Router`, including the output of `Router::merge`, + `Router::nest`, or `vespera!(...)` itself. +- Returns `std::io::Result<()>` — propagate with `?` from `main`. + --- ## Type Mapping Reference From 93af9989b6b6df161b23452e6e0d08afad325425 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 25 May 2026 17:42:22 +0900 Subject: [PATCH 6/8] Fix lint --- .oxlintrc.json | 3 - crates/vespera/src/serve.rs | 3 +- crates/vespera/tests/derive_garde_emit.rs | 10 +- crates/vespera/tests/jni_validation.rs | 27 +-- crates/vespera/tests/multipart_wire.rs | 4 +- crates/vespera/tests/validated_extractor.rs | 36 +-- crates/vespera_inprocess/benches/dispatch.rs | 11 +- crates/vespera_inprocess/src/lib.rs | 16 +- crates/vespera_inprocess/tests/binary_wire.rs | 43 ++-- crates/vespera_inprocess/tests/error_wire.rs | 3 +- .../tests/method_validation.rs | 16 +- crates/vespera_inprocess/tests/multi_app.rs | 3 +- crates/vespera_inprocess/tests/wire_format.rs | 4 +- crates/vespera_jni/src/lib.rs | 227 +++++++++--------- crates/vespera_macro/src/garde_emit.rs | 5 +- crates/vespera_macro/src/lib.rs | 4 +- .../src/parser/schema/struct_schema.rs | 6 +- examples/axum-example/src/routes/validated.rs | 1 - examples/rust-jni-demo/src/lib.rs | 1 - oxlint.config.ts | 1 + 20 files changed, 187 insertions(+), 237 deletions(-) delete mode 100644 .oxlintrc.json create mode 100644 oxlint.config.ts diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index bca1fb2..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["node_modules/eslint-plugin-devup/oxlintrc.json"] -} \ No newline at end of file diff --git a/crates/vespera/src/serve.rs b/crates/vespera/src/serve.rs index 59c9642..e4cbe66 100644 --- a/crates/vespera/src/serve.rs +++ b/crates/vespera/src/serve.rs @@ -30,8 +30,7 @@ pub trait Serve { /// `addr` accepts anything that implements /// [`tokio::net::ToSocketAddrs`] — strings (`"0.0.0.0:3000"`), /// tuples (`("127.0.0.1", 8080)`), [`std::net::SocketAddr`], … - fn serve(self, addr: impl ToSocketAddrs) - -> impl std::future::Future>; + fn serve(self, addr: impl ToSocketAddrs) -> impl std::future::Future>; } impl Serve for axum::Router { diff --git a/crates/vespera/tests/derive_garde_emit.rs b/crates/vespera/tests/derive_garde_emit.rs index d1b091a..4f8f040 100644 --- a/crates/vespera/tests/derive_garde_emit.rs +++ b/crates/vespera/tests/derive_garde_emit.rs @@ -224,10 +224,12 @@ fn nested_validation_option_none_skips_inner_checks() { fn nested_validation_option_some_runs_inner_checks() { let mut o = good_order(); o.billing_address = Some(Address { - city: String::new(), // violates min_length = 1 - postal_code: "ZZ999".to_owned(), // valid pattern + city: String::new(), // violates min_length = 1 + postal_code: "ZZ999".to_owned(), // valid pattern }); - let report = o.validate().expect_err("billing_address Some must validate"); + let report = o + .validate() + .expect_err("billing_address Some must validate"); let paths: Vec = report.iter().map(|(p, _)| p.to_string()).collect(); assert!( paths.iter().any(|p| p == "billing_address.city"), @@ -245,7 +247,7 @@ fn nested_validation_vec_iterates_with_indexed_path() { }, LineItem { sku: String::new(), // violates min_length=1 at index 1 - quantity: 0, // violates minimum=1 at index 1 + quantity: 0, // violates minimum=1 at index 1 }, ]; let report = o.validate().expect_err("line_items[1] should fail"); diff --git a/crates/vespera/tests/jni_validation.rs b/crates/vespera/tests/jni_validation.rs index 49c37e3..8d503ff 100644 --- a/crates/vespera/tests/jni_validation.rs +++ b/crates/vespera/tests/jni_validation.rs @@ -33,9 +33,7 @@ struct JniReq { age: u32, } -async fn jni_handler( - Validated(Json(_payload)): Validated>, -) -> &'static str { +async fn jni_handler(Validated(Json(_payload)): Validated>) -> &'static str { "ok" } @@ -74,9 +72,12 @@ fn decode_wire(resp: &[u8]) -> (Value, Vec) { assert!(resp.len() >= 4, "wire response too short"); let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); let header_len = u32::from_be_bytes(len_bytes) as usize; - assert!(4 + header_len <= resp.len(), "header_len overflows response"); - let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) - .expect("response header is valid JSON"); + assert!( + 4 + header_len <= resp.len(), + "header_len overflows response" + ); + let header: Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header is valid JSON"); let body = resp[4 + header_len..].to_vec(); (header, body) } @@ -167,7 +168,9 @@ fn jni_dispatch_short_username_returns_422_envelope_with_path() { .as_array() .unwrap_or_else(|| panic!("errors array missing in body: {error_body:#}")); assert!( - errors.iter().any(|e| e["path"].as_str() == Some("username")), + errors + .iter() + .any(|e| e["path"].as_str() == Some("username")), "expected `username` in error paths, got {error_body:#}" ); @@ -222,20 +225,14 @@ fn jni_dispatch_multiple_violations_envelope_contains_all_paths() { assert_eq!(header["status"].as_u64().unwrap(), 422); let errors = error_body["errors"].as_array().unwrap(); - let paths: Vec<&str> = errors - .iter() - .filter_map(|e| e["path"].as_str()) - .collect(); + let paths: Vec<&str> = errors.iter().filter_map(|e| e["path"].as_str()).collect(); assert!(paths.contains(&"username"), "got {paths:?}"); assert!(paths.contains(&"email"), "got {paths:?}"); assert!(paths.contains(&"age"), "got {paths:?}"); // NEW: hoisted validation_errors must mirror the body. let hoisted = header["validation_errors"].as_array().unwrap(); - let hoisted_paths: Vec<&str> = hoisted - .iter() - .filter_map(|e| e["path"].as_str()) - .collect(); + let hoisted_paths: Vec<&str> = hoisted.iter().filter_map(|e| e["path"].as_str()).collect(); assert!(hoisted_paths.contains(&"username"), "got {hoisted_paths:?}"); assert!(hoisted_paths.contains(&"email"), "got {hoisted_paths:?}"); assert!(hoisted_paths.contains(&"age"), "got {hoisted_paths:?}"); diff --git a/crates/vespera/tests/multipart_wire.rs b/crates/vespera/tests/multipart_wire.rs index 7cc4ef5..1965017 100644 --- a/crates/vespera/tests/multipart_wire.rs +++ b/crates/vespera/tests/multipart_wire.rs @@ -38,9 +38,7 @@ struct UploadResult { file_last_byte: u8, } -async fn upload_handler( - TypedMultipart(mut req): TypedMultipart, -) -> Json { +async fn upload_handler(TypedMultipart(mut req): TypedMultipart) -> Json { let mut buf = Vec::new(); let f = req.file.contents.as_file_mut(); // multipart parser leaves the file cursor at EOF after writing diff --git a/crates/vespera/tests/validated_extractor.rs b/crates/vespera/tests/validated_extractor.rs index 210d8ba..9cf8185 100644 --- a/crates/vespera/tests/validated_extractor.rs +++ b/crates/vespera/tests/validated_extractor.rs @@ -41,9 +41,7 @@ async fn valid_payload_returns_200() { .method("POST") .uri("/posts") .header("content-type", "application/json") - .body(Body::from( - r#"{"title":"My Post","content":"hello world"}"#, - )) + .body(Body::from(r#"{"title":"My Post","content":"hello world"}"#)) .unwrap(); let res = app.oneshot(req).await.unwrap(); @@ -78,8 +76,7 @@ async fn short_title_returns_422_with_path_keyed_envelope() { assert!( errors .iter() - .any(|e| e["path"].as_str() == Some("title") - && e["message"].as_str().is_some()), + .any(|e| e["path"].as_str() == Some("title") && e["message"].as_str().is_some()), "expected an error with path=\"title\", got {body:#}" ); } @@ -119,10 +116,7 @@ async fn multiple_violations_all_appear_in_envelope() { let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); let errors = body["errors"].as_array().unwrap(); - let paths: Vec<&str> = errors - .iter() - .filter_map(|e| e["path"].as_str()) - .collect(); + let paths: Vec<&str> = errors.iter().filter_map(|e| e["path"].as_str()).collect(); assert!(paths.contains(&"title"), "got {paths:?}"); assert!(paths.contains(&"content"), "got {paths:?}"); } @@ -215,10 +209,7 @@ fn good_payload() -> ::serde_json::Value { /// Send `payload` to `/all` and decode the response as /// `(status, body_json)`. Asserts `application/json` content-type when /// the status is `422` (the canonical validation envelope). -async fn dispatch( - app: Router, - payload: ::serde_json::Value, -) -> (u16, ::serde_json::Value) { +async fn dispatch(app: Router, payload: ::serde_json::Value) -> (u16, ::serde_json::Value) { let req = Request::builder() .method("POST") .uri("/all") @@ -229,13 +220,14 @@ async fn dispatch( let status = res.status().as_u16(); if status == 422 { assert_eq!( - res.headers().get("content-type").map(|v| v.to_str().unwrap()), + res.headers() + .get("content-type") + .map(|v| v.to_str().unwrap()), Some("application/json"), ); } - let body: ::serde_json::Value = - ::serde_json::from_str(&body_to_string(res.into_body()).await) - .unwrap_or(::serde_json::Value::Null); + let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await) + .unwrap_or(::serde_json::Value::Null); (status, body) } @@ -248,8 +240,7 @@ fn assert_envelope_has_field_error(body: &::serde_json::Value, field: &str) { assert!( errors .iter() - .any(|e| e["path"].as_str() == Some(field) - && e["message"].as_str().is_some()), + .any(|e| e["path"].as_str() == Some(field) && e["message"].as_str().is_some()), "expected an error with path=\"{field}\" + message, got {body:#}", ); } @@ -317,9 +308,7 @@ async fn rule_range_minimum_violation_returns_422() { #[schema(minimum = 0, maximum = 150)] age: i32, } - async fn handler( - Validated(::axum::Json(_)): Validated<::axum::Json>, - ) -> &'static str { + async fn handler(Validated(::axum::Json(_)): Validated<::axum::Json>) -> &'static str { "ok" } let app = Router::new().route("/n", post(handler)); @@ -398,8 +387,7 @@ async fn multiple_per_rule_violations_all_appear_in_envelope() { let (status, body) = dispatch(all_rules_router(), bad).await; assert_eq!(status, 422); for field in [ - "username", "email", "homepage", "addr_v4", "addr_v6", "age", "tags", - "nickname", + "username", "email", "homepage", "addr_v4", "addr_v6", "age", "tags", "nickname", ] { assert_envelope_has_field_error(&body, field); } diff --git a/crates/vespera_inprocess/benches/dispatch.rs b/crates/vespera_inprocess/benches/dispatch.rs index ee08cc1..b6e63bf 100644 --- a/crates/vespera_inprocess/benches/dispatch.rs +++ b/crates/vespera_inprocess/benches/dispatch.rs @@ -140,9 +140,7 @@ fn bench_dispatch_path(c: &mut Criterion) { BenchmarkId::new("owned_post_P2", body_kb), &body_kb, |b, _| { - b.iter(|| { - runtime.block_on(dispatch_owned(cached.clone(), template.clone())) - }); + b.iter(|| runtime.block_on(dispatch_owned(cached.clone(), template.clone()))); }, ); @@ -185,5 +183,10 @@ fn bench_wire_path(c: &mut Criterion) { drop(runtime); } -criterion_group!(benches, bench_router_path, bench_dispatch_path, bench_wire_path); +criterion_group!( + benches, + bench_router_path, + bench_dispatch_path, + bench_wire_path +); criterion_main!(benches); diff --git a/crates/vespera_inprocess/src/lib.rs b/crates/vespera_inprocess/src/lib.rs index f35f74c..37143f0 100644 --- a/crates/vespera_inprocess/src/lib.rs +++ b/crates/vespera_inprocess/src/lib.rs @@ -190,8 +190,7 @@ struct ValidationErrorItem { /// clone. pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { let result = dispatch_owned(router, envelope.clone()).await; - serde_json::to_string(&result) - .expect("ResponseEnvelope serialization is infallible") + serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") } /// Typed dispatch — returns a [`ResponseEnvelope`] directly. @@ -728,11 +727,7 @@ where } } - Ok(( - status, - resp_headers, - ResponseMetadata { version }, - )) + Ok((status, resp_headers, ResponseMetadata { version })) } /// Collect status, headers, body bytes, and metadata from an axum @@ -839,10 +834,7 @@ async fn dispatch_and_split( query: String, headers: HashMap, body: Body, -) -> Result< - (u16, HashMap, ResponseMetadata, Body), - (u16, String), -> { +) -> Result<(u16, HashMap, ResponseMetadata, Body), (u16, String)> { let Ok(http_method) = method_str.parse::() else { return Err(( 405, @@ -1237,8 +1229,6 @@ impl HttpBody for ChannelBody { } } - - /// Parse a wire-format request. On success returns the deserialised /// header and the owned body bytes (zero-copy via `Vec::split_off`). fn parse_wire_request(mut input: Vec) -> Result<(WireRequestHeader, Vec), String> { diff --git a/crates/vespera_inprocess/tests/binary_wire.rs b/crates/vespera_inprocess/tests/binary_wire.rs index 209bd6b..5a40cc9 100644 --- a/crates/vespera_inprocess/tests/binary_wire.rs +++ b/crates/vespera_inprocess/tests/binary_wire.rs @@ -110,11 +110,7 @@ fn encode_wire( } fn decode_wire(resp: &[u8]) -> (Value, Vec) { - assert!( - resp.len() >= 4, - "wire response too short ({})", - resp.len() - ); + assert!(resp.len() >= 4, "wire response too short ({})", resp.len()); let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); let header_len = u32::from_be_bytes(len_bytes) as usize; assert!( @@ -122,8 +118,8 @@ fn decode_wire(resp: &[u8]) -> (Value, Vec) { "header_len {header_len} overflows response ({} bytes)", resp.len() ); - let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) - .expect("response header JSON parses"); + let header: Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON parses"); let body = resp[4 + header_len..].to_vec(); (header, body) } @@ -164,7 +160,9 @@ fn post_json_body_echoes_back() { #[test] fn post_octet_stream_preserves_non_utf8_bytes() { // Includes 0x00, 0xFF, and an invalid UTF-8 sequence (0xC0 0xC0). - let raw: Vec = vec![0x00, 0x01, 0x02, 0xC0, 0xC0, 0xFE, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF]; + let raw: Vec = vec![ + 0x00, 0x01, 0x02, 0xC0, 0xC0, 0xFE, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF, + ]; let (header, body) = dispatch(encode_wire( "POST", "/echo/bytes", @@ -327,11 +325,7 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { ); // Request body chunks to push. - let chunks: Vec> = vec![ - b"hello ".to_vec(), - b"world".to_vec(), - b"!".to_vec(), - ]; + let chunks: Vec> = vec![b"hello ".to_vec(), b"world".to_vec(), b"!".to_vec()]; let chunks_iter = Mutex::new(chunks.into_iter()); let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; @@ -342,12 +336,9 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { received_clone.lock().unwrap().extend_from_slice(chunk); }; - let header_bytes = vespera_inprocess::dispatch_bidirectional_streaming( - header_only_wire, - pull_chunk, - on_chunk, - ) - .await; + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; let (header, body) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(200)); @@ -395,12 +386,9 @@ async fn dispatch_bidirectional_streaming_large_request_body() { received_clone.lock().unwrap().extend_from_slice(chunk); }; - let header_bytes = vespera_inprocess::dispatch_bidirectional_streaming( - header_only_wire, - pull_chunk, - on_chunk, - ) - .await; + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; let (header, _) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(200)); @@ -444,5 +432,8 @@ async fn dispatch_streaming_async_emits_error_wire_on_malformed_input() { !body.is_empty(), "error response must carry the error message in its body" ); - assert!(chunks.is_empty(), "no chunks should fire on malformed input"); + assert!( + chunks.is_empty(), + "no chunks should fire on malformed input" + ); } diff --git a/crates/vespera_inprocess/tests/error_wire.rs b/crates/vespera_inprocess/tests/error_wire.rs index 7059f6a..29b57df 100644 --- a/crates/vespera_inprocess/tests/error_wire.rs +++ b/crates/vespera_inprocess/tests/error_wire.rs @@ -12,8 +12,7 @@ fn decode(resp: &[u8]) -> (Value, Vec) { let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); let header_len = u32::from_be_bytes(len_bytes) as usize; assert!(4 + header_len <= resp.len(), "header_len overflows"); - let header: Value = - serde_json::from_slice(&resp[4..4 + header_len]).expect("header JSON"); + let header: Value = serde_json::from_slice(&resp[4..4 + header_len]).expect("header JSON"); let body = resp[4 + header_len..].to_vec(); (header, body) } diff --git a/crates/vespera_inprocess/tests/method_validation.rs b/crates/vespera_inprocess/tests/method_validation.rs index a57d233..8f7505f 100644 --- a/crates/vespera_inprocess/tests/method_validation.rs +++ b/crates/vespera_inprocess/tests/method_validation.rs @@ -27,11 +27,8 @@ fn router_with_get_test() -> Router { async fn method_with_space_returns_405() { // Before the fix, "BAD METHOD" was silently coerced to GET and the // request hit the GET handler at /test with status 200. - let response = dispatch_typed( - router_with_get_test(), - &envelope_with_method("BAD METHOD"), - ) - .await; + let response = + dispatch_typed(router_with_get_test(), &envelope_with_method("BAD METHOD")).await; assert_eq!(response.status, 405); assert!( response.body.contains("BAD METHOD"), @@ -42,15 +39,13 @@ async fn method_with_space_returns_405() { #[tokio::test(flavor = "current_thread")] async fn empty_method_returns_405() { - let response = - dispatch_typed(router_with_get_test(), &envelope_with_method("")).await; + let response = dispatch_typed(router_with_get_test(), &envelope_with_method("")).await; assert_eq!(response.status, 405); } #[tokio::test(flavor = "current_thread")] async fn method_with_control_char_returns_405() { - let response = - dispatch_typed(router_with_get_test(), &envelope_with_method("GET\n")).await; + let response = dispatch_typed(router_with_get_test(), &envelope_with_method("GET\n")).await; assert_eq!(response.status, 405); } @@ -58,8 +53,7 @@ async fn method_with_control_char_returns_405() { async fn valid_method_dispatches_normally() { // Sanity check: a real GET still reaches the handler. The 405 // short-circuit must not regress the happy path. - let response = - dispatch_typed(router_with_get_test(), &envelope_with_method("GET")).await; + let response = dispatch_typed(router_with_get_test(), &envelope_with_method("GET")).await; assert_eq!(response.status, 200); assert_eq!(response.body, "would-have-been-wrong"); } diff --git a/crates/vespera_inprocess/tests/multi_app.rs b/crates/vespera_inprocess/tests/multi_app.rs index 9286815..0a955ff 100644 --- a/crates/vespera_inprocess/tests/multi_app.rs +++ b/crates/vespera_inprocess/tests/multi_app.rs @@ -48,8 +48,7 @@ fn encode_wire(method: &str, path: &str, app: Option<&str>) -> Vec { if let Some(a) = app { header.insert("app".to_owned(), Value::String(a.to_owned())); } - let header_bytes = - serde_json::to_vec(&Value::Object(header)).expect("header serialise"); + let header_bytes = serde_json::to_vec(&Value::Object(header)).expect("header serialise"); let header_len = u32::try_from(header_bytes.len()).unwrap(); let mut wire = Vec::with_capacity(4 + header_bytes.len()); wire.extend_from_slice(&header_len.to_be_bytes()); diff --git a/crates/vespera_inprocess/tests/wire_format.rs b/crates/vespera_inprocess/tests/wire_format.rs index f41fd8b..597373f 100644 --- a/crates/vespera_inprocess/tests/wire_format.rs +++ b/crates/vespera_inprocess/tests/wire_format.rs @@ -23,8 +23,8 @@ fn dispatch(wire: Vec) -> (Value, Vec) { 4 + header_len <= resp.len(), "header_len {header_len} overflows response" ); - let header: Value = serde_json::from_slice(&resp[4..4 + header_len]) - .expect("response header is valid JSON"); + let header: Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header is valid JSON"); let body = resp[4 + header_len..].to_vec(); (header, body) } diff --git a/crates/vespera_jni/src/lib.rs b/crates/vespera_jni/src/lib.rs index b456804..f5f95de 100644 --- a/crates/vespera_jni/src/lib.rs +++ b/crates/vespera_jni/src/lib.rs @@ -205,9 +205,7 @@ mod jni_impl { RUNTIME.spawn(async move { let response = tokio::spawn(vespera_inprocess::dispatch_from_bytes_async(input)) .await - .unwrap_or_else(|_| { - vespera_inprocess::error_wire(500, "panic in Rust engine") - }); + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); // Re-attach to JVM on this worker thread; subsequent // dispatches on the same thread will hit the TLS fast @@ -241,7 +239,9 @@ mod jni_impl { /// regular `error_wire(...)` response (header + small body) and /// the `OutputStream` is **not** written to. #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming<'local>( + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming< + 'local, + >( mut unowned_env: EnvUnowned<'local>, _class: JClass<'local>, request_bytes: JByteArray<'local>, @@ -273,26 +273,23 @@ mod jni_impl { // even for streams with thousands of chunks. let _ = jvm.attach_current_thread( |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>( - 8, - |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &stream_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - // Any IOException thrown by write() is left - // pending on the env; clear it so subsequent - // chunks on the same thread aren't poisoned. - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }, - ) + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(chunk)?; + let arr_obj: JObject = arr.into(); + env.call_method( + &stream_global, + jni_str!("write"), + jni_sig!("([B)V"), + &[JValue::Object(&arr_obj)], + )?; + // Any IOException thrown by write() is left + // pending on the env; clear it so subsequent + // chunks on the same thread aren't poisoned. + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }) }, ); }, @@ -331,7 +328,9 @@ mod jni_impl { /// `error_wire(...)` response in the returned bytes and neither /// stream is touched. #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming<'local>( + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming< + 'local, + >( mut unowned_env: EnvUnowned<'local>, _class: JClass<'local>, header_bytes: JByteArray<'local>, @@ -359,18 +358,17 @@ mod jni_impl { let push_jvm = jvm; let push_global = output_global; - let header_response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( - header_input, - // Pull request body chunks from Java InputStream. - // Runs on a tokio blocking thread (spawn_blocking - // inside dispatch_bidirectional_streaming). - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>( - 8, - |env| { + let header_response = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( + header_input, + // Pull request body chunks from Java InputStream. + // Runs on a tokio blocking thread (spawn_blocking + // inside dispatch_bidirectional_streaming). + move || -> Option> { + let result: jni::errors::Result>> = pull_jvm + .attach_current_thread(|env| { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; let n = env .call_method( @@ -389,36 +387,35 @@ mod jni_impl { let mut data = env.convert_byte_array(&arr)?; data.truncate(usize::try_from(n).unwrap_or(0)); Ok(Some(data)) - }, - ) - }); - result.ok().flatten() - }, - // Push response body chunks to Java OutputStream. - // Runs on the tokio worker driving the dispatch. - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &push_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - }, - ); - }, - )) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + }) + }); + result.ok().flatten() + }, + // Push response body chunks to Java OutputStream. + // Runs on the tokio worker driving the dispatch. + |chunk: &[u8]| { + let _ = push_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(chunk)?; + let arr_obj: JObject = arr.into(); + env.call_method( + &push_global, + jni_str!("write"), + jni_sig!("([B)V"), + &[JValue::Object(&arr_obj)], + )?; + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }) + }, + ); + }, + )) + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); Ok(env.byte_array_from_slice(&header_response)?.into()) }) @@ -439,7 +436,9 @@ mod jni_impl { /// (length-prefixed JSON). On error `outputStream` is not /// touched. #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader<'local>( + pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader< + 'local, + >( mut unowned_env: EnvUnowned<'local>, _class: JClass<'local>, request_bytes: JByteArray<'local>, @@ -543,51 +542,53 @@ mod jni_impl { // recovery semantics depend on which side of the header // callback the panic landed. let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming_with_header( - header_input, - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; - let n = env - .call_method( - &pull_global, - jni_str!("read"), - jni_sig!("([B)I"), - &[JValue::Object(arr.as_ref())], - )? - .i()?; - if env.exception_check() { - env.exception_clear(); - } - if n <= 0 { - return Ok(None); - } - let mut data = env.convert_byte_array(&arr)?; - data.truncate(usize::try_from(n).unwrap_or(0)); - Ok(Some(data)) - }) - }); - result.ok().flatten() - }, - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - write_chunk_to_stream(env, &push_global, chunk) - }) - }, - ); - }, - |header_bytes: &[u8]| { - let _ = header_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - call_header_consumer(env, &header_for_cb, header_bytes) - }, - ); - }, - )); + RUNTIME.block_on( + vespera_inprocess::dispatch_bidirectional_streaming_with_header( + header_input, + move || -> Option> { + let result: jni::errors::Result>> = pull_jvm + .attach_current_thread(|env| { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; + let n = env + .call_method( + &pull_global, + jni_str!("read"), + jni_sig!("([B)I"), + &[JValue::Object(arr.as_ref())], + )? + .i()?; + if env.exception_check() { + env.exception_clear(); + } + if n <= 0 { + return Ok(None); + } + let mut data = env.convert_byte_array(&arr)?; + data.truncate(usize::try_from(n).unwrap_or(0)); + Ok(Some(data)) + }) + }); + result.ok().flatten() + }, + |chunk: &[u8]| { + let _ = push_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + write_chunk_to_stream(env, &push_global, chunk) + }) + }, + ); + }, + |header_bytes: &[u8]| { + let _ = header_jvm.attach_current_thread( + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ); + }, + ), + ); })); Ok(()) diff --git a/crates/vespera_macro/src/garde_emit.rs b/crates/vespera_macro/src/garde_emit.rs index 4022ef8..4e0ebfb 100644 --- a/crates/vespera_macro/src/garde_emit.rs +++ b/crates/vespera_macro/src/garde_emit.rs @@ -269,10 +269,7 @@ fn emit_rule_blocks( // ── Pattern (pattern = "..." → static LazyLock) ──────────── if let Some(pattern) = &c.pattern { - let static_ident = format_ident!( - "__VESPERA_PATTERN_{}", - field_name.to_ascii_uppercase() - ); + let static_ident = format_ident!("__VESPERA_PATTERN_{}", field_name.to_ascii_uppercase()); blocks.push(quote! { { static #static_ident: ::std::sync::LazyLock< diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 1989b21..f19f6a7 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -324,9 +324,7 @@ pub fn schema_type(input: TokenStream) -> TokenStream { // definitions are textually different (the pre-registered one is // synthesised by `schema_macro`; the derive-emitted one is the // expanded struct token stream). - if ignore_schema - && let Some(metadata) = generated_metadata - { + if ignore_schema && let Some(metadata) = generated_metadata { let name = metadata.name.clone(); SCHEMA_STORAGE .lock() diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index aa9dd71..290a9ca 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -239,10 +239,8 @@ fn apply_constraints_to_schema_ref(schema_ref: &mut SchemaRef, c: &SchemaConstra // mem::replace lets us move the Ref out without leaving an // invalid value behind; the placeholder is overwritten // before the function returns. - let taken = std::mem::replace( - schema_ref, - SchemaRef::Inline(Box::new(Schema::object())), - ); + let taken = + std::mem::replace(schema_ref, SchemaRef::Inline(Box::new(Schema::object()))); if let SchemaRef::Ref(reference) = taken { let mut wrapper = Schema { all_of: Some(vec![SchemaRef::Ref(reference)]), diff --git a/examples/axum-example/src/routes/validated.rs b/examples/axum-example/src/routes/validated.rs index 07da234..576e8b5 100644 --- a/examples/axum-example/src/routes/validated.rs +++ b/examples/axum-example/src/routes/validated.rs @@ -51,4 +51,3 @@ pub async fn create_validated_user( ) -> Json { Json(req) } - diff --git a/examples/rust-jni-demo/src/lib.rs b/examples/rust-jni-demo/src/lib.rs index f3d80eb..467d211 100644 --- a/examples/rust-jni-demo/src/lib.rs +++ b/examples/rust-jni-demo/src/lib.rs @@ -236,5 +236,4 @@ mod tests { // Invalid method parses → fallback GET, unknown route → 404 assert_eq!(parse(&json)["status"], 404); } - } diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..f3beb76 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1 @@ +export { default } from 'eslint-plugin-devup/oxlint-config' From 07d52dbf6e983949f339c683264065605706789a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 26 May 2026 00:17:33 +0900 Subject: [PATCH 7/8] Fix lint --- .github/workflows/CI.yml | 2 +- .github/workflows/deploy-pages.yml | 6 +- Cargo.lock | 372 +++++++++--------- Cargo.toml | 2 +- apps/landing/package.json | 2 +- .../app/documentation/_components/edit.tsx | 2 +- .../src/components/side-menu/index.tsx | 10 +- .../components/table-of-contents/iterator.tsx | 2 +- bun.lock | 254 +++++------- crates/vespera_inprocess/Cargo.toml | 2 +- 10 files changed, 300 insertions(+), 354 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9875017..c5f553f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -53,7 +53,7 @@ jobs: cargo fmt cargo tarpaulin --out Lcov Stdout --engine llvm - name: Upload to codecov.io - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 294816c..d2a29b9 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -37,7 +37,7 @@ jobs: bun-${{ runner.os }}- - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 @@ -60,7 +60,7 @@ jobs: run: touch apps/landing/out/.nojekyll - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./apps/landing/out @@ -74,4 +74,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/Cargo.lock b/Cargo.lock index acb3aaa..d2452f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -83,9 +92,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4754a624e5ae42081f464514be454b39711daae0458906dacde5f4c632f33a8" +checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" dependencies = [ "arrow-arith", "arrow-array", @@ -101,9 +110,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b3141e0ec5145a22d8694ea8b6d6f69305971c4fa1c1a13ef0195aef2d678b" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ "arrow-array", "arrow-buffer", @@ -115,9 +124,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -133,9 +142,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", @@ -145,9 +154,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "646bbb821e86fd57189c10b4fcdaa941deaf4181924917b0daa92735baa6ada5" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ "arrow-array", "arrow-buffer", @@ -166,9 +175,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ "arrow-buffer", "arrow-schema", @@ -179,9 +188,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d8f1870e03d4cbed632959498bcc84083b5a24bded52905ae1695bd29da45b" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ "arrow-array", "arrow-buffer", @@ -192,9 +201,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18228633bad92bff92a95746bbeb16e5fc318e8382b75619dec26db79e4de4c0" +checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" dependencies = [ "arrow-array", "arrow-buffer", @@ -205,15 +214,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" [[package]] name = "arrow-select" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash 0.8.12", "arrow-array", @@ -225,9 +234,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e968097061b3c0e9fe3079cf2e703e487890700546b5b0647f60fca1b5a8d8" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ "arrow-array", "arrow-buffer", @@ -290,15 +299,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -366,9 +375,9 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.2" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ "axum", "axum-core", @@ -449,9 +458,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -503,9 +512,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" @@ -564,9 +573,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -592,7 +601,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -683,9 +692,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -787,31 +796,30 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -819,12 +827,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1034,9 +1042,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1145,9 +1153,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1262,9 +1270,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -1284,9 +1292,9 @@ dependencies = [ [[package]] name = "garde" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a74b56a4039a46e8c91cc9d84e8a7df4e1f8b24239ca57d1304b3263cb599b9" +checksum = "5d7f479d28d1daedf23970890723b8774d120aa627abcb33c1d7c93d0965e6c3" dependencies = [ "compact_str", "garde_derive", @@ -1298,9 +1306,9 @@ dependencies = [ [[package]] name = "garde_derive" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7224c08ec489e2840af29ed882b47f7f6ac8f4ce15c275d9fc0d6d1b94578ae6" +checksum = "7b0252cacdca8e6f30a900e6d436fbf294e23f674b9288ca4a5bd35976bef87c" dependencies = [ "proc-macro2", "quote", @@ -1350,7 +1358,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1399,6 +1407,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -1444,12 +1458,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1530,9 +1538,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1544,7 +1552,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1701,9 +1708,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1711,12 +1718,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1746,29 +1753,18 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1839,10 +1835,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1921,9 +1919,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1933,14 +1931,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.5", ] [[package]] @@ -1977,9 +1975,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "mac_address" @@ -2105,7 +2103,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2121,9 +2119,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2222,6 +2220,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -2268,9 +2276,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pgvector" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" dependencies = [ "serde", ] @@ -2299,12 +2307,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -2328,9 +2330,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2522,9 +2524,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -2533,13 +2535,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2563,9 +2565,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" @@ -2598,9 +2600,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] @@ -2771,21 +2773,21 @@ dependencies = [ "futures-util", "http", "mime", - "rand 0.10.0", + "rand 0.10.1", "thiserror", ] [[package]] name = "rust_decimal" -version = "1.41.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -2816,9 +2818,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -2830,18 +2832,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -3037,9 +3039,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3086,9 +3088,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3122,9 +3124,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" dependencies = [ "futures-executor", "futures-util", @@ -3137,9 +3139,9 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", @@ -3218,9 +3220,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3387,7 +3389,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "rust_decimal", "serde", @@ -3429,7 +3431,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "rust_decimal", "serde", "serde_json", @@ -3700,9 +3702,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3733,9 +3735,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3764,9 +3766,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.9+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -3776,9 +3778,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -3801,9 +3803,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", @@ -3871,15 +3873,15 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "typetag" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" dependencies = [ "erased-serde", "inventory", @@ -3890,9 +3892,9 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", @@ -3958,9 +3960,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4079,11 +4081,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4092,7 +4094,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -4103,9 +4105,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4117,9 +4119,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4127,9 +4129,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4140,9 +4142,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4183,9 +4185,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4197,14 +4199,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.7", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -4459,9 +4461,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4475,6 +4477,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4556,9 +4564,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -4620,9 +4628,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index d54d296..9093910 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ vespera_jni = { path = "crates/vespera_jni", version = "0.1.51" } # Runtime validator backend. Held behind the `validation` feature on # the `vespera` crate; users never name it directly — the proc-macro # emits paths via `::vespera::__validation::garde::...`. -garde = { version = "0.22", default-features = false, features = ["email", "url", "pattern"] } +garde = { version = "0.23", default-features = false, features = ["email", "url", "pattern"] } [workspace.lints.clippy] all = { level = "warn", priority = -1 } diff --git a/apps/landing/package.json b/apps/landing/package.json index 3839f67..e05185d 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -13,7 +13,7 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.45", + "@devup-ui/components": "^0.1.46", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", diff --git a/apps/landing/src/app/documentation/_components/edit.tsx b/apps/landing/src/app/documentation/_components/edit.tsx index 904632c..ef5ceb0 100644 --- a/apps/landing/src/app/documentation/_components/edit.tsx +++ b/apps/landing/src/app/documentation/_components/edit.tsx @@ -6,9 +6,9 @@ export function Edit() { alignItems="center" borderRadius="$spacingSpacing08" cursor="pointer" + data-group gap="$spacingSpacing08" py="$spacingSpacing08" - role="group" > - + { setSelected(value ?? null) @@ -60,7 +63,6 @@ export function SideMenu({ pl="$spacingSpacing16" pr="$spacingSpacing12" py={['$spacingSpacing12', null, null, null, '$spacingSpacing08']} - role="group" > =5.96" }, "peerDependencies": { "react": "*" } }, "sha512-hhZqiKbqdlHB2qUOqvhgEN6vkED+VZ4Rzt6HoPw26bOx98rve6AoafWY/meYKRFCE9oHwmNpsz2ARyLboWWVVg=="], - "@devup-api/utils": ["@devup-api/utils@0.1.9", "", {}, "sha512-a0lsKflFQvQnBPDoSfTVbUfoHLW6J3U0K08NC3uaAfLOEao6sVFpxJhqcw2Fy4FDH5tjUQ7XB0p4HYyMRaC76A=="], + "@devup-api/utils": ["@devup-api/utils@0.1.10", "", {}, "sha512-FT5YCVdq0W2ftTavg+iQou5viJFgBV0/SNOalgUu4/lvaflP6qwP1C2ECbJiW6u+I3PtwloKyXLuu2CFYpIT1g=="], - "@devup-api/webpack-plugin": ["@devup-api/webpack-plugin@0.1.12", "", { "dependencies": { "@devup-api/core": "^0.1.16", "@devup-api/generator": "^0.1.22", "@devup-api/utils": "^0.1.9" } }, "sha512-D9ulnE1U1uQ22kMzG5+hFT9cGhvQRUezmtesAXQfpbr3s/xgA8h+CPOhnayGqY0UYe3JVrkdLs2zVNYD4nCaHQ=="], + "@devup-api/webpack-plugin": ["@devup-api/webpack-plugin@0.1.13", "", { "dependencies": { "@devup-api/core": "^0.1.18", "@devup-api/generator": "^0.1.24", "@devup-api/utils": "^0.1.10" } }, "sha512-dQMqcMMdNUtzUHdaVYm29aIAU2S3+1EXLnWI3zsbVfF8X8isWqLlmwPS5aioY7iGDIYW4nL3C4gkIrhvT2pgpA=="], - "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.7", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-Sd3jeZ1swtAL+wf1STTW+Ay60jbZ9emWIJhnXfUK9+A/h+LLqiMpu9fI+m8IXxKM+5ijDF12lcrN+Vm3rT4o/g=="], + "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.9", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-Rj50un5MzTUiKdS7rlDh8DKrwhI4s4O+L1HtSr+Pw+/bo0mSMRRM8pr11umd7gUAXIlh0qgllwe3iagP9gZh6g=="], - "@devup-ui/components": ["@devup-ui/components@0.1.45", "", { "dependencies": { "@devup-ui/react": "^1.0.36", "clsx": "^2.1", "react": "^19.2.6" } }, "sha512-ND5G3nVT+3DzZS6BS4FHDP7b5pQ7/qu7l/Q4ZglxpkJo3B1XLD6O4ZpNap5NZ9J2YGPEmKT7qHdphd/qINvTtg=="], + "@devup-ui/components": ["@devup-ui/components@0.1.46", "", { "dependencies": { "@devup-ui/react": "^1.0.37", "clsx": "^2.1", "react": "^19.2.6" } }, "sha512-vZGMsACbB8YlBdrSLLq+3Lp2MoWw3vxoL6bYeepVqGiHLQaEZcyG1Iv1uymy7hAZYRlX7lgttJMhtVTyfyVdKA=="], - "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.57", "typescript-eslint": "^8.57" } }, "sha512-HLoIDIHgUsEJ4z8a0VGMx48DYIKfnv/jZPIQWFlK6s67n6x+R33loY+5O/mggbIDntGY2lknGTKFfKgD4hahPQ=="], + "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.15", "", { "dependencies": { "@typescript-eslint/utils": "^8.59", "typescript-eslint": "^8.59" } }, "sha512-vSOqvMTETHeF45X1JUxkkEkzoHTTgl8u/bJ3D9sybAoWNxvhcus5aDCOP1WHvJPQ1IG8/EMilxmrCyWNdkHJnA=="], - "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.74", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68", "@devup-ui/webpack-plugin": "^1.0.58" }, "peerDependencies": { "next": "*" } }, "sha512-GU2tz7RNZchnlzVxfXB7Sp2AMd/k3mjUyW/Tf6tDqhYgavYUz06DCV7lz4uojsMe/e1Q62IpwONINXicFBCH8Q=="], + "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.77", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74", "@devup-ui/webpack-plugin": "^1.0.60" }, "peerDependencies": { "next": "*" } }, "sha512-Ty2Jgv1AA2x0pttw3SF0qflB/Mfsx8+JtFm/j5VXwp/UjbMBkKSA19IR9sGRN9n+4DqpG5aOl7lJJmCNvmW6VQ=="], - "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.4", "", {}, "sha512-zAWxUawHphhzdcouy1vTVVkief0C7poCnDToxxQ4/rg9IGumIqmox7lSDRi579145qcd1jEH90uElCC0LpUiDA=="], + "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.7", "", {}, "sha512-KIVxYZCtkuLS29sDO/JRSWjO1fCQw/TnBD1J5u1KsLo134Q+8RogebWM/OeEJmMmGuiB9uiz06uzjG4h3BXLVg=="], - "@devup-ui/react": ["@devup-ui/react@1.0.35", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-OrgmKxziAkUgtVuZQX9I+pfKT/Fwa33JSqVUORJ171jqjjyLXGqtCyXsBvJVWfI4dmorMkJXJFVV7hh2ceJPNg=="], + "@devup-ui/react": ["@devup-ui/react@1.0.37", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-zeHO2ke7X5vnM8w9vl4knDmXameG0X8OCb5E+qZPS2G4tsFJ98B3LKhioHTtnTs8YxxFaErRjUoeXylG4AiMpg=="], - "@devup-ui/reset-css": ["@devup-ui/reset-css@1.0.23", "", { "dependencies": { "@devup-ui/react": "^1.0.35" } }, "sha512-tJ+YKODanxR6hIHBAMK8Ldwsw7bHR3djrnL0GBtAZ0B6VDOytuFa2XgsD+Jj+VSR3PQLoDBQSbClmgm5l17dTQ=="], + "@devup-ui/reset-css": ["@devup-ui/reset-css@1.0.24", "", { "dependencies": { "@devup-ui/react": "^1.0.36" } }, "sha512-yz2Pkbh5KyhqvHExajmXkwVUTBhh64XN4TyE6jgs7gogYE7ab8glPHtsBPEARTIPhK0MjLorDJswNVdMrbDw7w=="], - "@devup-ui/wasm": ["@devup-ui/wasm@1.0.72", "", {}, "sha512-hrnTY9cyTLDt8LMqWTRYG5eMs+7CGyaHObi4onA5TiCtmicb4/NWOM6THL4A0d4f5EzC6WzRDuobSMm+2wiIxA=="], + "@devup-ui/wasm": ["@devup-ui/wasm@1.0.74", "", {}, "sha512-pxlUTj2A/cZrf3KuFas1d2Xtfch998JPiYL7M8r227PZyG7CfcBBdniM7AcQCEx7mQrZ8NMM3DIldp2ZnD+1CA=="], - "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.58", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-JrQQd3p0kQ1kASL5E783OsfXLdzh4Aaiugy1901wN7enF7fcKvZthIxDMd3ionLDXym4ZAb+l0QnRFKlMW2Gcg=="], + "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.60", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-e62sqaU7KNsmB76BmY+T8exWuBZ09i9L0li5wxqA7WS4bUDZKRV7eN4jIZ2/RBZ1tdWfmTcXqaEYoPv8pjUA8g=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -126,12 +126,10 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], @@ -218,25 +216,25 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], "@next/mdx": ["@next/mdx@16.2.6", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-0hdoSkzRbyud1dNRRDiyqD9FrxR2wwdiW+ffhYx+n+fXrFOJ7Nwpi8o7nUz2LiiM44BB9M0eIO1Evy3BBrS50A=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], "@npmcli/config": ["@npmcli/config@8.3.4", "", { "dependencies": { "@npmcli/map-workspaces": "^3.0.2", "@npmcli/package-json": "^5.1.1", "ci-info": "^4.0.0", "ini": "^4.1.2", "nopt": "^7.2.1", "proc-log": "^4.2.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" } }, "sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw=="], @@ -310,11 +308,11 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.11", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-4JfaSf6/ql9AFAsRWaWulz40gS86bDgSr15pWCI3o+oX3sdZ0ZR8AOeNrCEqyIrV6wFxnCfhFi1kWjOlZ+66Ew=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-NbpiBCmeHTRuVHeV5+U+1bzmxyTW5Dzp2sCeE6Hx+ZJTJWFK9dsm8VZmRc7LQP9/ZORsF620PvgUk67AwiBo4A=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.5", "", {}, "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], - "@tanstack/react-query": ["@tanstack/react-query@5.100.5", "", { "dependencies": { "@tanstack/query-core": "5.100.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -326,7 +324,7 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], @@ -334,7 +332,7 @@ "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -350,9 +348,9 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], @@ -364,27 +362,27 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], @@ -398,8 +396,6 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -428,9 +424,9 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], - "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -438,7 +434,7 @@ "bun-test-env-dom": ["bun-test-env-dom@1.0.3", "", { "dependencies": { "@happy-dom/global-registrator": ">=20.0", "@testing-library/dom": ">=10.4", "@testing-library/jest-dom": ">=6.9", "@testing-library/react": ">=16.3", "@testing-library/user-event": ">=14.6" } }, "sha512-Ozepvzk1s/bJSxABEjbI+Ztnm3CN1b0vRSvf0Qa0rTnuO7S0wKN2cUTsXdyIJuqE6OnlAhyoe2NGqkdeemz5/Q=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -446,14 +442,10 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -488,7 +480,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "csstype-extra": ["csstype-extra@0.1.27", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-jxh1cGhqyDliNrbfT+Evg4JLSMUz0lXqoNAxtBUZQcfKng8uO+wE8JxzXuy9PwPifpVcXQQt2D6m+D0+V0MC0A=="], + "csstype-extra": ["csstype-extra@0.1.29", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-9y4phbWzHTetVUxRlx2Lm6WULf/ciwtZ0AmaQnI8pwtEHQMw6BXkXLXBnehGJGSFsZ4zXc6MOoBCfzPbHroMMQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -520,7 +512,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -538,7 +530,7 @@ "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -554,7 +546,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], @@ -648,8 +640,6 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -658,8 +648,6 @@ "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], @@ -704,8 +692,6 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -736,7 +722,7 @@ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], @@ -794,8 +780,6 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -822,8 +806,6 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -918,15 +900,15 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], + "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -970,8 +952,6 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], @@ -1016,9 +996,9 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1066,9 +1046,7 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -1148,15 +1126,13 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1188,11 +1164,11 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], + "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1256,43 +1232,35 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@devup-ui/components/@devup-ui/react": ["@devup-ui/react@1.0.36", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-FjRW7YCuVuusWOtFrD11kYO4KFxVFMoU/gRgBUMdhU9tzuCqrUFB+9glPXIbq6qFpFB4dkoUmNxv7XSEFZNzzw=="], - - "@devup-ui/components/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/git/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@npmcli/map-workspaces/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/package-json/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -1302,27 +1270,23 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "eslint-mdx/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "eslint-plugin-react/eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], - "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "normalize-package-data/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-install-checks/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-pick-manifest/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1332,7 +1296,7 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1340,12 +1304,10 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "unified-engine/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "unified-engine/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "unified-engine/ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="], - "vfile-reporter/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1356,41 +1318,19 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@devup-ui/components/@devup-ui/react/csstype-extra": ["csstype-extra@0.1.29", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-9y4phbWzHTetVUxRlx2Lm6WULf/ciwtZ0AmaQnI8pwtEHQMw6BXkXLXBnehGJGSFsZ4zXc6MOoBCfzPbHroMMQ=="], - - "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "eslint-plugin-react/eslint/@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - - "eslint-plugin-react/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - - "eslint-plugin-react/eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - - "eslint-plugin-react/eslint/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - - "eslint-plugin-react/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - - "eslint-plugin-react/eslint/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-plugin-react/eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "eslint-plugin-react/eslint/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1398,12 +1338,8 @@ "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "eslint-plugin-react/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/crates/vespera_inprocess/Cargo.toml b/crates/vespera_inprocess/Cargo.toml index e366801..e6999cf 100644 --- a/crates/vespera_inprocess/Cargo.toml +++ b/crates/vespera_inprocess/Cargo.toml @@ -18,7 +18,7 @@ serde_json = "1" tokio = { version = "1", features = ["rt"] } [dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { version = "0.8", features = ["html_reports"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [[bench]] From dac22dc728245f66624b67e3ff9bd6c72747ab55 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 26 May 2026 00:18:44 +0900 Subject: [PATCH 8/8] Add patch note --- .changepacks/changepack_log_T_Ks87b0wcAt8G79QLxTv.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_T_Ks87b0wcAt8G79QLxTv.json diff --git a/.changepacks/changepack_log_T_Ks87b0wcAt8G79QLxTv.json b/.changepacks/changepack_log_T_Ks87b0wcAt8G79QLxTv.json new file mode 100644 index 0000000..8eac70b --- /dev/null +++ b/.changepacks/changepack_log_T_Ks87b0wcAt8G79QLxTv.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Minor","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Minor","libs/vespera-bridge/build.gradle.kts":"Minor"},"note":"Implement lsp and plugins","date":"2026-05-25T15:18:38.254483600Z"} \ No newline at end of file