diff --git a/Cargo.lock b/Cargo.lock index 1a9d916..61e4d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -93,9 +99,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -151,9 +157,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.4" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -161,9 +167,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -206,6 +212,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -336,7 +348,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -415,11 +427,16 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -440,9 +457,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.19.0" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ "bit-set", "regex-automata", @@ -467,9 +484,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fluent-uri" @@ -536,7 +553,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -756,16 +773,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -910,9 +928,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -929,9 +947,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-macro" @@ -971,9 +989,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -982,9 +1000,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21" +checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa" dependencies = [ "ahash", "bytecount", @@ -1010,18 +1028,18 @@ dependencies = [ [[package]] name = "jsonschema-regex" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181" +checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3" dependencies = [ "regex-syntax", ] [[package]] name = "jsonschema-value" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0" +checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44" dependencies = [ "ahash", "bytecount", @@ -1035,11 +1053,11 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "11.0.0" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac", @@ -1146,9 +1164,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1172,6 +1190,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndarray" version = "0.17.2" @@ -1372,6 +1396,7 @@ dependencies = [ "regex", "serde", "serde_json", + "socket2", "tera", "tokio", "unicode-normalization", @@ -1431,7 +1456,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1523,9 +1548,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -1764,14 +1789,14 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "referencing" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c" +checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e" dependencies = [ "ahash", "fluent-uri", @@ -1989,7 +2014,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2051,6 +2076,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -2077,9 +2108,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -2159,9 +2190,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2170,13 +2201,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2208,9 +2239,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tera" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52fca06a22977165c6821c26e2bf0387cd7e0822107a6854d5fbb787307c4194" +checksum = "61e0cadeeb54426080b4c5395d59769ce8293f542cc25511c167c742b2175809" dependencies = [ "ahash", "globset", @@ -2222,22 +2253,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2291,18 +2322,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -2329,7 +2351,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2348,9 +2370,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -2488,9 +2510,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -2605,9 +2627,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2618,9 +2640,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2628,22 +2650,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2767,30 +2789,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", @@ -2808,13 +2830,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -2868,7 +2890,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2ba9d77..33495ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ matchit = { git = "https://github.com/ibraheemdev/matchit", branch = "master" } # Serialization / Data formats serde = "1.0.229" serde_json = "1.0.151" -jsonschema = { version = "0.56.0", default-features = false } +jsonschema = { version = "0.57.0", default-features = false } # Template engines tera = { version = "2", features = ["glob_fs", "fast"] } @@ -54,3 +54,4 @@ url = "2.5.8" jsonwebtoken = { version = "11.0.0", features = ["rust_crypto"] } regex = "1.13.1" unicode-normalization = "0.1.25" +socket2 = { version = "0.6.5", features = ["all"] } diff --git a/oxapy/__init__.py b/oxapy/__init__.py index 0f89cca..6f4d6b6 100644 --- a/oxapy/__init__.py +++ b/oxapy/__init__.py @@ -5,14 +5,13 @@ import subprocess import time import base64 +import signal import typing import mimetypes import hmac import orjson as json import hashlib -from functools import partial - from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler @@ -63,60 +62,51 @@ def set_watch_dir(self, watch_dir: str): self.__watch_dir = watch_dir return self - def run(self, reload: bool = False, workers: typing.Optional[int] = None): + def run( + self, + reload: bool = False, + processes: typing.Optional[int] = None, + workers: typing.Optional[int] = None, + ): """ - Starts the server or the supervisor process. - - If `reload` is enabled and the current process is not flagged as a worker, - it launches the supervisor to watch for file changes. Otherwise, it starts - the actual HTTP server instance. + Starts the server, optionally as a pool of OS processes. Args: - reload (bool): Whether to enable auto-reloading on file changes. Defaults to False. - workers (int, optional): The number of worker processes to run. Defaults to None. + reload (bool): Watch for file changes and restart the pool. Defaults to False. + processes (int, optional): Number of OS-level worker processes to run, + all sharing the listening port via SO_REUSEPORT. Defaults to 1. + workers (int, optional): Tokio worker threads per process (passed through + to the Rust runtime's block_on). """ - if reload and os.environ.get("OXAPY_WORKER") != "1": - self._run_supervisor() - else: + if os.environ.get("OXAPY_WORKER") == "1": + # We are a spawned worker: just run the actual server. return super().run(workers) - def _run_supervisor(self): - """ - Manages the file watcher and the child worker process. + num_processes = processes if processes and processes > 0 else 1 + + if not reload and num_processes <= 1: + return super().run(workers) - Sets up a directory observer. When a watched file is modified, created, - or deleted, it gracefully terminates the current worker process and - spawns a fresh one. + self._run_supervisor(num_processes, reload) + + def _run_supervisor(self, num_processes: int, reload: bool): + """ + Manages a pool of `num_processes` worker processes. + + In reload mode, any watched file change tears down and restarts the whole + pool; so does a worker that dies unexpectedly, since the surviving workers + would otherwise keep serving the pre-crash code. Outside of reload mode, a + worker that dies unexpectedly is respawned on its own (self-healing pool); + a worker that exits cleanly is left down, and once every worker has exited + cleanly the supervisor returns. """ env = os.environ.copy() env["OXAPY_WORKER"] = "1" - reload_requested = threading.Event() - changed_file_path = "" - - def on_file_changed(event): - """Triggers a reload sequence when a watched file is modified.""" - nonlocal changed_file_path - changed_file_path = event.src_path - reload_requested.set() - - handler = PatternMatchingEventHandler( - patterns=self.__patterns, ignore_directories=True - ) - handler.on_modified = on_file_changed - handler.on_created = on_file_changed - handler.on_deleted = on_file_changed - - observer = Observer() - observer.schedule(handler, self.__watch_dir, recursive=True) - observer.start() - def spawn_worker() -> subprocess.Popen: - """Spawns the child server process with the worker environment flag.""" return subprocess.Popen([sys.executable] + sys.argv, env=env) def terminate_worker(proc: subprocess.Popen): - """Gracefully terminates a worker process, escalating to a kill if it hangs.""" if proc and proc.poll() is None: proc.terminate() try: @@ -124,31 +114,85 @@ def terminate_worker(proc: subprocess.Popen): except subprocess.TimeoutExpired: proc.kill() - worker_process = spawn_worker() + def terminate_pool(pool: list[subprocess.Popen]): + for proc in pool: + terminate_worker(proc) + + pool = [spawn_worker() for _ in range(num_processes)] + + reload_requested = threading.Event() + changed_file_path = "" + observer = None + + def restart_pool(reason: str) -> list[subprocess.Popen]: + """ + Tears down every worker and spawns a fresh pool, so that no process + keeps serving stale code. + + Args: + reason (str): Human readable reason, shown in the restart notice. + + Returns: + list[subprocess.Popen]: The freshly spawned pool. + """ + time.sleep(0.3) + reload_requested.clear() + terminate_pool(pool) + print(f"Reloading {num_processes} worker(s)... ({reason})") + return [spawn_worker() for _ in range(num_processes)] + + # Let SIGTERM unwind the same way SIGINT/KeyboardInterrupt does. + def _on_sigterm(signum, frame): + raise KeyboardInterrupt + + previous_sigterm = signal.signal(signal.SIGTERM, _on_sigterm) + + if reload: + + def on_file_changed(event): + nonlocal changed_file_path + changed_file_path = event.src_path + reload_requested.set() + + handler = PatternMatchingEventHandler( + patterns=self.__patterns, ignore_directories=True + ) + handler.on_modified = on_file_changed + handler.on_created = on_file_changed + handler.on_deleted = on_file_changed + + observer = Observer() + observer.schedule(handler, self.__watch_dir, recursive=True) + observer.start() try: while True: - if reload_requested.wait(timeout=0.2): - time.sleep(0.3) - reload_requested.clear() - terminate_worker(worker_process) + if reload and reload_requested.wait(timeout=0.2): filename = os.path.basename(changed_file_path) - print(f"Reloading... ({filename} changed)") - worker_process = spawn_worker() - elif worker_process.poll() is not None: - if worker_process.returncode != 0: - reload_requested.wait() - time.sleep(0.3) - reload_requested.clear() - worker_process = spawn_worker() - else: - break + pool = restart_pool(f"{filename} changed") + continue + + time.sleep(0.2) + + for i, proc in enumerate(pool): + if proc.poll() is None: + continue + if proc.returncode != 0: + if reload: + pool = restart_pool(f"worker {i} exited with code {proc.returncode}") + break + print(f"Worker {i} exited with code {proc.returncode}, restarting...") + pool[i] = spawn_worker() + elif not reload and all(p.poll() is not None for p in pool): + return except KeyboardInterrupt: pass finally: - observer.stop() - observer.join() - terminate_worker(worker_process) + signal.signal(signal.SIGTERM, previous_sigterm) + if observer: + observer.stop() + observer.join() + terminate_pool(pool) def _b64_encode(data: bytes) -> str: diff --git a/src/into_response.rs b/src/into_response.rs index 109cda7..af33c7a 100644 --- a/src/into_response.rs +++ b/src/into_response.rs @@ -11,13 +11,16 @@ use crate::{ }; type Error = Box; +const TEXT_PLAIN: HeaderValue = HeaderValue::from_static("text/plain"); +const APP_JSON: HeaderValue = HeaderValue::from_static("application/json"); impl TryFrom for Response { type Error = Error; + #[inline] fn try_from(val: String) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, TEXT_PLAIN); Ok(Response { status: Status::OK, headers, @@ -29,9 +32,10 @@ impl TryFrom for Response { impl TryFrom> for Response { type Error = Error; + #[inline] fn try_from(val: Bound) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, APP_JSON); Ok(Response { status: Status::OK, headers, @@ -43,9 +47,10 @@ impl TryFrom> for Response { impl TryFrom<(String, Status)> for Response { type Error = Error; + #[inline] fn try_from(val: (String, Status)) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, TEXT_PLAIN); Ok(Response { status: val.1, headers, @@ -57,9 +62,10 @@ impl TryFrom<(String, Status)> for Response { impl TryFrom<(Bound<'_, PyAny>, Status)> for Response { type Error = Error; + #[inline] fn try_from(val: (Bound, Status)) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, APP_JSON); Ok(Response { status: val.1, headers, @@ -69,9 +75,10 @@ impl TryFrom<(Bound<'_, PyAny>, Status)> for Response { } impl From for Response { + #[inline] fn from(val: Status) -> Self { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, APP_JSON); Response { status: val, headers, @@ -111,6 +118,7 @@ impl From for Response { impl TryFrom for Response { type Error = PyErr; + #[inline] fn try_from(cors: Cors) -> Result { let mut response = Response::from(Status::NO_CONTENT); cors.apply_headers(&mut response)?; @@ -181,11 +189,11 @@ pub fn convert_to_response(result: Py, py: Python<'_>) -> PyResult, Status), - String, Bound ) } diff --git a/src/lib.rs b/src/lib.rs index 45bf9b6..31abe6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,8 +163,8 @@ impl Oxapy { todo!("dummy init") } - #[pyo3(signature=(reload = false, workers = None))] - fn run(&self, reload: bool, workers: Option) -> Py { + #[pyo3(signature=(reload = false, processes = None, workers = None))] + fn run(&self, reload: bool, processes: Option, workers: Option) -> Py { todo!("dummy fonction") } @@ -460,9 +460,25 @@ impl HttpServer { } } +fn create_listener(addr: SocketAddr) -> std::io::Result { + let socket = socket2::Socket::new( + socket2::Domain::for_address(addr), + socket2::Type::STREAM, + None, + )?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + socket.bind(&addr.into())?; + socket.listen(4096)?; + socket.set_nonblocking(true)?; + + TcpListener::from_std(socket.into()) +} + impl HttpServer { async fn run_server(&self) -> PyResult<()> { - let listener = TcpListener::bind(self.addr).await?; + let listener = create_listener(self.addr)?; println!("Listening on {}", self.addr); let shutdown = ShutDownSignal::new()?; @@ -531,12 +547,7 @@ impl HttpServer { loop { tokio::select! { Some(pr) = request_receiver.recv() => { - let response = call_python_handler(&pr.middlewares, &pr.match_route, &pr.request, self.is_async) - .await - .unwrap_or_else(Response::from) - .call_wrapper(&pr) - .apply_cors(&pr.cors)?; - let _ = pr.response_sender.send(response); + pr.respond(self.is_async).await; }, _ = shutdown.wait() => break, } @@ -545,38 +556,92 @@ impl HttpServer { } } -async fn call_python_handler( - middlewares: &Option>, - match_route: &Option, - request: &Request, - is_async: bool, -) -> PyResult { - if let Some(match_route) = match_route { - let mut result = Python::attach(|py| { +impl ProcessRequest { + pub async fn respond(self, is_async: bool) { + let Self { + match_route, + middlewares, + wrapper, + request, + response_sender, + cors, + } = self; + + let response = Self::dispatch(match_route, middlewares, wrapper, request, is_async) + .await + .unwrap_or_else(Response::from) + .apply_cors(&cors) + .unwrap_or_else(Response::from); + + let _ = response_sender.send(response); + } + + async fn dispatch( + match_route: Option, + middlewares: Option>, + wrapper: Option>>, + request: Request, + is_async: bool, + ) -> PyResult { + let Some(match_route) = match_route else { + return match wrapper.as_deref() { + Some(wrapper) => Python::attach(|py| { + let request = Py::new(py, request)?; + Self::apply_wrapper(py, &request, Status::NOT_FOUND.into(), wrapper) + }), + None => Ok(Status::NOT_FOUND.into()), + }; + }; + + let (result, request) = Python::attach(|py| -> PyResult<_> { + let request = Py::new(py, request)?; let route = &match_route.value; - let params = &match_route.params; - let kwargs = build_route_params(py, params)?; - match middlewares { - Some(middlewares) => MiddlewareChain::execute( + let kwargs = if match_route.params.is_empty() { + None + } else { + Some(build_route_params(py, &match_route.params)?) + }; + + let res = match middlewares.as_deref() { + Some(chain) => MiddlewareChain::execute( py, - middlewares, + chain, route.sequence, &route.handler, - (request.clone(),), - kwargs, + (&request,), + kwargs.as_ref(), ), - None => route.handler.call(py, (request.clone(),), Some(&kwargs)), - } + None => route.handler.call(py, (&request,), kwargs.as_ref()), + }?; + + Ok((res, request)) })?; - if is_async { - result = Python::attach(|py| into_future(result.into_bound(py)))?.await?; - } + let result = if is_async { + Python::attach(|py| into_future(result.into_bound(py)))?.await? + } else { + result + }; + + Python::attach(|py| { + let response = into_response::convert_to_response(result, py)?; + match wrapper.as_deref() { + Some(wrapper) => Self::apply_wrapper(py, &request, response, wrapper), + None => Ok(response), + } + }) + } - Python::attach(|py| into_response::convert_to_response(result, py)) - } else { - Ok(Status::NOT_FOUND.into()) + #[inline] + fn apply_wrapper( + py: Python<'_>, + py_request: &Py, + response: Response, + wrapper: &Py, + ) -> PyResult { + let wrapped = wrapper.call(py, (py_request, response), None)?; + into_response::convert_to_response(wrapped, py) } } diff --git a/src/middleware.rs b/src/middleware.rs index b042887..c9f04f1 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -26,14 +26,14 @@ impl MiddlewareChain { route_sequence: usize, route_handler: &Py, args: A, - kwargs: Bound<'py, PyDict>, + kwargs: Option<&Bound<'py, PyDict>>, ) -> PyResult> where A: PyCallArgs<'py>, { let handler = Self::build_middleware_chain(py, middlewares, route_sequence, route_handler, 0)?; - handler.call(py, args, Some(&kwargs)) + handler.call(py, args, kwargs) } fn build_middleware_chain( diff --git a/src/request.rs b/src/request.rs index 634cae5..f6b1707 100644 --- a/src/request.rs +++ b/src/request.rs @@ -3,13 +3,14 @@ use tokio::sync::oneshot; use ahash::{HashMap, HashMapExt}; use http_body_util::BodyExt; +use hyper::body::{Body as _, Bytes}; +use hyper::header::{CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue}; +use hyper::{Method, Uri}; use pyo3::{ exceptions::{PyAttributeError, PyException}, prelude::*, types::PyDict, }; - -use hyper::Uri; use pyo3_stub_gen::derive::*; use url::form_urlencoded; @@ -18,7 +19,7 @@ use crate::status::Status; use crate::{ Context, IntoPyException, ProcessRequest, json, multipart::File, templating::Template, }; -use crate::{middleware::Middleware, routing::MatchRoute, routing::OwnedMatchRoute}; +use crate::{middleware::Middleware, routing::OwnedMatchRoute}; use crate::{multipart::parse_multipart, response::Body}; /// HTTP request object containing information about the incoming request. @@ -50,15 +51,9 @@ use crate::{multipart::parse_multipart, response::Body}; #[pyclass(from_py_object)] #[derive(Clone, Debug, Default)] pub struct Request { - /// The HTTP method of the request (e.g., GET, POST, PUT). - #[pyo3(get)] - pub method: String, - /// The full URI of the request including path and query string. - #[pyo3(get)] - pub uri: String, - /// HTTP headers as key-value pairs. - #[pyo3(get)] - pub headers: HashMap, + pub method: Method, + pub uri: Uri, + pub headers: HeaderMap, /// The raw data content of the request as a string, if present. #[pyo3(get)] pub data: Option, @@ -91,14 +86,47 @@ impl Request { #[new] #[gen_stub(override_return_type(type_repr = "typing_extensions.Self", imports = ("typing_extensions",)))] pub fn new(method: String, uri: String, headers: HashMap) -> Self { + let method = method.parse::().unwrap_or(Method::GET); + let uri = uri.parse::().unwrap_or_default(); + let mut header_map = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + if let (Ok(name), Ok(value)) = + (HeaderName::try_from(name), HeaderValue::from_str(&value)) + { + header_map.append(name, value); + } + } Self { method, uri, - headers, + headers: header_map, ..Default::default() } } + /// The HTTP method of the request (e.g., GET, POST, PUT). + #[getter] + fn method(&self) -> String { + self.method.as_str().to_string() + } + + /// The full URI of the request including path and query string. + #[getter] + fn uri(&self) -> String { + self.uri.to_string() + } + + #[getter] + fn headers(&self, py: Python<'_>) -> PyResult> { + let dict = PyDict::new(py); + for (name, value) in self.headers.iter() { + if let Ok(value) = value.to_str() { + dict.set_item(name.as_str(), value)?; + } + } + Ok(dict.into()) + } + /// Parse the request body as JSON and return it as a dictionary. /// /// Args: @@ -178,15 +206,13 @@ impl Request { /// return {"name": name, "age": age} /// ``` #[getter] - fn query(&self) -> PyResult> { - let uri: Uri = self.uri.parse().into_py_exception()?; - if let Some(query_string) = uri.query() { - let parsed_query = form_urlencoded::parse(query_string.as_bytes()) - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect(); - return Ok(parsed_query); + fn query(&self) -> HashMap { + match self.uri.query() { + Some(query_string) => form_urlencoded::parse(query_string.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(), + None => HashMap::new(), } - Ok(HashMap::default()) } /// Get cookie value by the name from the request headers @@ -210,7 +236,8 @@ impl Request { /// render(request, "index.html.j2", {"theme": theme}) /// ``` fn get_cookie(&self, name: &str) -> Option<&str> { - let cookie = self.headers.get("cookie")?; + let cookie = self.headers.get(COOKIE)?; + let cookie = cookie.to_str().ok()?; let cookies = cookie.split(';'); for c in cookies { let (k, v) = c.trim().split_once('=')?; @@ -252,7 +279,7 @@ impl Request { self, ctx: Arc, ) -> Result, hyper::http::Error> { - if self.method == "OPTIONS" + if self.method == Method::OPTIONS && let Some(ref cors) = ctx.cors { return Response::try_from((**cors).clone()) @@ -260,13 +287,10 @@ impl Request { .try_into(); } - let method = self.method.clone(); - let uri = self.uri.clone(); - let matched = ctx.routers.iter().find_map(|router| { router - .find(&method, &uri) - .map(|m| (m, router.middlewares.clone())) + .find(self.method.as_str(), self.uri.path()) + .map(|m| (OwnedMatchRoute::from(m), router.middlewares.clone())) }); if let Some((match_route, middlewares)) = matched { @@ -280,15 +304,13 @@ impl Request { async fn handle_found_route( self, ctx: &Context, - match_route: MatchRoute<'_>, + match_route: OwnedMatchRoute, middlewares: Option>, ) -> Result, hyper::http::Error> { let (response_sender, response_receiver) = oneshot::channel(); - let owned_match_route = OwnedMatchRoute::from(match_route); - let process_request = ProcessRequest { - match_route: Some(owned_match_route), + match_route: Some(match_route), middlewares, request: self, response_sender, @@ -332,28 +354,23 @@ impl Request { } pub struct RequestBuilder { - method: String, - uri: String, - headers: HashMap, + method: Method, + uri: Uri, + headers: HeaderMap, app_data: Option>>, template: Option>, - req: hyper::Request, + body: hyper::body::Incoming, } impl RequestBuilder { pub fn new(req: hyper::Request) -> Self { - let hyper_headers = req.headers(); - let mut headers = HashMap::with_capacity(hyper_headers.len()); - - for (k, v) in hyper_headers { - headers.insert(k.to_string(), v.to_str().unwrap_or_default().to_string()); - } + let (parts, body) = req.into_parts(); Self { - method: req.method().to_string(), - uri: req.uri().to_string(), - headers, - req, + method: parts.method, + uri: parts.uri, + headers: parts.headers, + body, app_data: None, template: None, } @@ -370,11 +387,23 @@ impl RequestBuilder { } pub async fn build(self) -> PyResult { - let mut request = Request::new(self.method, self.uri, self.headers); + let mut request = Request { + method: self.method, + uri: self.uri, + headers: self.headers, + ..Default::default() + }; - let bytes = self.req.collect().await.into_py_exception()?.to_bytes(); + let bytes = match self.body.size_hint().upper() { + Some(0) => Bytes::new(), + _ => self.body.collect().await.into_py_exception()?.to_bytes(), + }; - if let Some(content_type) = request.headers.get("content-type") { + if let Some(content_type) = request + .headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + { if content_type.starts_with("multipart/form-data") { let parsed_multipart = parse_multipart(content_type, bytes) .await @@ -382,17 +411,16 @@ impl RequestBuilder { request.form = parsed_multipart.fields; request.files = parsed_multipart.files; } else if content_type.starts_with("application/json") { - let body = String::from_utf8_lossy(&bytes).into_owned(); - if !body.is_empty() { - request.data = Some(body); - } - } else { - let form = String::from_utf8_lossy(&bytes).into_owned(); - if !form.is_empty() { - request.form = form_urlencoded::parse(form.as_bytes()) - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); + if !bytes.is_empty() { + request.data = Some(match String::from_utf8(bytes.to_vec()) { + Ok(body) => body, + Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(), + }); } + } else if !bytes.is_empty() { + request.form = form_urlencoded::parse(bytes.as_ref()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); } } diff --git a/src/response.rs b/src/response.rs index 8529fc5..e70bc85 100644 --- a/src/response.rs +++ b/src/response.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use futures_util::{StreamExt, stream}; use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody}; +use hyper::StatusCode; use hyper::{ HeaderMap, body::{Bytes, Frame}, @@ -18,14 +19,14 @@ use pyo3::types::{PyBytes, PyString}; use pyo3_stub_gen::derive::*; use crate::cors::Cors; -use crate::{IntoPyException, ProcessRequest, Status, convert_to_response, json}; +use crate::{IntoPyException, Status, json}; pub type Body = BoxBody; #[derive(Clone)] pub enum ResponseBody { Bytes(Bytes), - Stream(Arc), + Stream(Arc Body + Send + Sync>), } /// HTTP response object that is returned from request handlers. @@ -59,6 +60,17 @@ pub struct Response { pub headers: HeaderMap, } +#[inline(always)] +fn with_ct(status: Status, content_type: HeaderValue, body: Bytes) -> Response { + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, content_type); + Response { + status, + headers, + body: ResponseBody::Bytes(body), + } +} + #[gen_stub_pymethods] #[pymethods] impl Response { @@ -83,25 +95,24 @@ impl Response { /// # Return error /// response = Response("Not authorized", status=Status.UNAUTHORIZED) /// ``` + #[inline] #[new] #[pyo3(signature=(body, status = Status::OK , content_type="application/json"))] #[gen_stub(override_return_type(type_repr = "typing_extensions.Self", imports = ("typing_extensions",)))] - pub fn new(body: Bound, status: Status, content_type: &str) -> PyResult { - let content_type = HeaderValue::from_str(content_type).into_py_exception()?; - - if content_type == "application/json" { - return Self::from_json(body, status, content_type); + pub fn new(body: Bound<'_, PyAny>, status: Status, content_type: &str) -> PyResult { + if let Ok(s) = body.cast::() { + let text = s.to_str().into_py_exception()?.to_owned(); + let ct = HeaderValue::from_str(content_type).into_py_exception()?; + return Ok(with_ct(status, ct, Bytes::from(text))); } - if body.is_instance_of::() { - return Self::from_bytes(body.extract()?, status, content_type); + if let Ok(b) = body.cast::() { + let ct = HeaderValue::from_str(content_type).into_py_exception()?; + return Ok(with_ct(status, ct, Bytes::copy_from_slice(b.as_bytes()))); } - if body.is_instance_of::() { - return Self::from_str(body.to_string(), status, content_type); - } - - Err(PyTypeError::new_err("Unsupported response type")) + let ct = HeaderValue::from_str(content_type).into_py_exception()?; + Ok(with_ct(status, ct, json::dumps(&body)?.into())) } /// Get the response body as a string. @@ -118,10 +129,9 @@ impl Response { let s = str::from_utf8(b).into_py_exception()?; Ok(s.to_string()) } - _ => { - let message = "response body is streaming and cannot be extracted as a string"; - Err(PyTypeError::new_err(message)) - } + _ => Err(PyTypeError::new_err( + "response body is streaming and cannot be extracted as a string", + )), } } @@ -141,12 +151,13 @@ impl Response { /// print(f"{name}: {value}") /// ``` #[getter] - fn headers(&self) -> Vec<(&str, &str)> { - // we return vec of tuple over dictionary because, - // dict can't store diff value with same key + fn headers(&self) -> PyResult> { self.headers .iter() - .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) + .map(|(k, v)| { + let value = v.to_str().into_py_exception()?.to_owned(); + Ok((k.as_str().to_owned(), value)) + }) .collect() } @@ -234,11 +245,18 @@ impl Response { secure: bool, samesite: &str, ) -> PyResult<()> { - let mut cookie_header = - format!("{name}={value}; Path={path}; Max-Age={max_age}; SameSite={samesite}"); + use std::fmt::Write; + + let mut cookie_header = String::with_capacity(128); + write!( + cookie_header, + "{name}={value}; Path={path}; Max-Age={max_age}; SameSite={samesite}" + ) + .map_err(|e| PyTypeError::new_err(e.to_string()))?; if !domain.is_empty() { - cookie_header.push_str(&format!("; Domain={domain}")); + cookie_header.push_str("; Domain="); + cookie_header.push_str(domain); } if httponly { cookie_header.push_str("; HttpOnly"); @@ -263,41 +281,6 @@ impl Response { self } - fn from_str(s: String, status: Status, content_type: HeaderValue) -> PyResult { - Ok(Self { - body: ResponseBody::Bytes(Bytes::from(s)), - status, - headers: HeaderMap::from_iter([(CONTENT_TYPE, content_type)]), - }) - } - - fn from_bytes(b: &[u8], status: Status, content_type: HeaderValue) -> PyResult { - Ok(Self { - status, - body: ResponseBody::Bytes(Bytes::copy_from_slice(b)), - headers: HeaderMap::from_iter([(CONTENT_TYPE, content_type)]), - }) - } - - fn from_json(obj: Bound, status: Status, content_type: HeaderValue) -> PyResult { - Ok(Self { - status, - body: ResponseBody::Bytes(json::dumps(&obj)?.into()), - headers: HeaderMap::from_iter([(CONTENT_TYPE, content_type)]), - }) - } - - pub(crate) fn call_wrapper(mut self, pr: &ProcessRequest) -> Self { - if let Some(wrapper) = &pr.wrapper { - self = Python::attach(|py| { - let result = wrapper.call(py, (pr.request.clone(), self), None)?; - convert_to_response(result, py) - }) - .unwrap_or_else(Response::from); - } - self - } - pub(crate) fn apply_cors(mut self, cors: &Option>) -> PyResult { if let Some(cors) = cors { cors.apply_headers(&mut self)?; @@ -352,7 +335,7 @@ impl Redirect { #[new] #[gen_stub(override_return_type(type_repr = "typing_extensions.Self", imports = ("typing_extensions",)))] fn new(location: String) -> PyResult> { - let mut headers = HeaderMap::new(); + let mut headers = HeaderMap::with_capacity(2); headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); headers.insert(LOCATION, location.parse().into_py_exception()?); Ok(PyClassInitializer::from(Response { @@ -494,16 +477,29 @@ impl FileStreaming { status: Status, content_type: &str, ) -> PyResult> { - let file = fs::File::open(path)?; - let chunk_iter = ChunkIter { file, buf_size }; - let stream = stream::iter(chunk_iter).map(|bytes| Ok(Frame::data(bytes))); - let body = StreamBody::new(Box::pin(stream)); - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, content_type.parse().into_py_exception()?); + fs::File::open(path)?; + + let content_type: HeaderValue = content_type.parse().into_py_exception()?; + + let path = Arc::new(path.to_owned()); + let make: Arc Body + Send + Sync> = { + let path = Arc::clone(&path); + Arc::new(move || { + let file = + fs::File::open(&*path).expect("file existed at construction but vanished"); + let chunk_iter = ChunkIter { file, buf_size }; + let stream = stream::iter(chunk_iter).map(|b| Ok(Frame::data(b))); + BodyExt::boxed(StreamBody::new(Box::pin(stream))) + }) + }; + + let mut headers = HeaderMap::with_capacity(2); + headers.insert(CONTENT_TYPE, content_type); headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + Ok(PyClassInitializer::from(Response { status, - body: ResponseBody::Stream(Arc::new(BodyExt::boxed(body))), + body: ResponseBody::Stream(make), headers, }) .add_subclass(Self)) @@ -513,15 +509,22 @@ impl FileStreaming { impl TryFrom for hyper::Response { type Error = hyper::http::Error; + #[inline] fn try_from(response: Response) -> Result { - let mut builder = hyper::Response::builder().status(response.status as u16); - for (name, value) in response.headers.iter() { - builder = builder.header(name, value); - } - - match response.body { - ResponseBody::Bytes(b) => builder.body(Full::new(b).boxed()), - ResponseBody::Stream(s) => builder.body(Arc::try_unwrap(s).unwrap()), - } + let Response { + status, + headers, + body, + } = response; + + let body: Body = match body { + ResponseBody::Bytes(b) => BodyExt::boxed(Full::new(b)), + ResponseBody::Stream(make) => make(), + }; + + let mut res = hyper::Response::new(body); + *res.status_mut() = StatusCode::from_u16(status as u16)?; + *res.headers_mut() = headers; // move, no clone + Ok(res) } } diff --git a/src/routing.rs b/src/routing.rs index 93a27c5..bf80ef5 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -1,8 +1,11 @@ use std::sync::Arc; use ahash::HashMap; -use pyo3::{Py, PyAny, prelude::*}; +use pyo3::exceptions::PyValueError; +use pyo3::sync::PyOnceLock; +use pyo3::{Py, PyAny, prelude::*, types::PyMapping}; use pyo3_stub_gen::derive::*; +use regex::Regex; use crate::{IntoPyException, middleware::Middleware}; @@ -83,7 +86,8 @@ impl Route { } } - fn __call__(&self, handler: Py) -> PyResult { + fn __call__(&self, handler: Py, py: Python<'_>) -> PyResult { + static_check_handler(handler.clone_ref(py), &self.path, py)?; Ok(Self { handler: Arc::new(handler), ..self.clone() @@ -107,18 +111,66 @@ macro_rules! methods { #[gen_stub_pyfunction] #[pyfunction] #[pyo3(signature = (path, handler = None))] - pub fn $method(path: String, handler: Option>, py: Python<'_>) -> Route { - Route { + pub fn $method(path: String, handler: Option>, py: Python<'_>) -> PyResult { + if let Some(handler_func) = handler.as_ref() { + static_check_handler(handler_func.clone_ref(py), &path, py)?; + } + + Ok(Route { method: stringify!($method).to_uppercase(), path, sequence: 0, handler: Arc::new(handler.unwrap_or(py.None())) - } + }) } )+ }; } +fn static_check_handler(handler: Py, path: &str, py: Python<'_>) -> PyResult<()> { + static INSPECT: PyOnceLock> = PyOnceLock::new(); + let inspect = INSPECT.get_or_try_init(py, || py.import("inspect").map(|m| m.into()))?; + + let params = extract_params(path, py)?; + + let signature = inspect + .call_method1(py, "signature", (handler,))? + .into_bound(py); + let parameters = signature.getattr("parameters")?.cast_into::()?; + let keys: Vec = parameters.keys()?.extract()?; + + for param in params { + let name = param.strip_prefix('*').unwrap_or(¶m); + if !keys.iter().any(|k| k.as_str() == name) { + return Err(PyValueError::new_err(format!( + "Missing required route argument '{param}'" + ))); + } + } + + Ok(()) +} + +fn extract_params(path: &str, py: Python<'_>) -> PyResult> { + static RE: PyOnceLock = PyOnceLock::new(); + let re = RE + .get_or_try_init(py, || Regex::new(r"\{([^}]+)\}")) + .into_py_exception()?; + + let params = re + .captures_iter(path) + .map(|cap| { + let arg = cap[1].to_string(); + match arg.split_once(':') { + Some((a, _)) => a.to_string(), + None => arg, + } + }) + .collect(); + + Ok(params) +} + methods!( /// Registers an HTTP GET route. /// diff --git a/tests/app.py b/tests/app.py index aafac9e..a3eacd5 100644 --- a/tests/app.py +++ b/tests/app.py @@ -1,20 +1,18 @@ -import multiprocessing -workers = multiprocessing.cpu_count() - - from oxapy import Oxapy, Router, get, post def main(): ( Oxapy(("0.0.0.0", 3000)) + .channel_capacity(1000 * 8) + .max_connections(1000 * 8) .attach( Router() .route(get("/", lambda _: "")) .route(get("/user/{id:int}", lambda _, id: str(id))) .route(post("/user", lambda _: "")) ) - .run(workers=workers) + .run(reload=True, processes=8) )