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..5cdabd2 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,21 @@ 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 = "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" @@ -592,6 +628,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" @@ -602,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" @@ -698,6 +800,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 +843,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]] @@ -1134,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" @@ -1573,6 +1755,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 +2174,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 +2213,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 +2339,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 +2568,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 +2906,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 +2916,7 @@ dependencies = [ "chrono", "derive_more", "futures-util", - "itertools", + "itertools 0.14.0", "log", "mac_address", "ouroboros", @@ -2675,7 +2930,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.28.0", "thiserror", "time", "tracing", @@ -2696,12 +2951,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 +2967,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 +2997,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 +3510,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 +3674,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" @@ -3717,10 +3988,13 @@ dependencies = [ "axum", "axum-extra", "chrono", + "garde", + "serde", "serde_json", "tempfile", "tokio", "tokio-cron-scheduler", + "tower", "tower-layer", "tower-service", "vespera_core", @@ -3743,6 +4017,7 @@ name = "vespera_inprocess" version = "0.1.51" dependencies = [ "axum", + "criterion", "http", "http-body-util", "serde", @@ -3905,6 +4180,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/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/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/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_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/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/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/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/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/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/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..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": { @@ -4246,6 +4267,46 @@ "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" + ] } } }, @@ -4274,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 bc03522..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": { @@ -4246,6 +4267,46 @@ "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" + ] } } }, @@ -4274,6 +4335,9 @@ { "name": "uuid_items" }, + { + "name": "validated" + }, { "name": "third" } 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 +}