diff --git a/.bcr/MODULE.bazel b/.bcr/MODULE.bazel new file mode 100644 index 0000000..c09a051 --- /dev/null +++ b/.bcr/MODULE.bazel @@ -0,0 +1,4 @@ +module( + name = "khttpd", + version = "{{VERSION}}", +) diff --git a/.bcr/metadata.template.json b/.bcr/metadata.template.json new file mode 100644 index 0000000..2f7ed35 --- /dev/null +++ b/.bcr/metadata.template.json @@ -0,0 +1,12 @@ +{ + "homepage": "https://github.com/ClangTools/khttpd", + "maintainers": [ + { + "email": "kekxv@github.com", + "github": "ClangTools" + } + ], + "repository": ["github:ClangTools/khttpd"], + "versions": [], + "yanked_versions": {} +} diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml new file mode 100644 index 0000000..c47884a --- /dev/null +++ b/.bcr/presubmit.yml @@ -0,0 +1,16 @@ +matrix: + platform: + - ubuntu2404 + - macos + - windows + bazel: + - 7.x + - 8.x + +tasks: + verify_targets: + name: Verify build targets + platform: ${{ platform }} + bazel: ${{ bazel }} + build_targets: + - '@khttpd//...' diff --git a/.bcr/source.template.json b/.bcr/source.template.json new file mode 100644 index 0000000..be184f1 --- /dev/null +++ b/.bcr/source.template.json @@ -0,0 +1,4 @@ +{ + "url": "https://github.com/ClangTools/khttpd/releases/download/{{TAG}}/khttpd-{{VERSION}}.tar.gz", + "strip_prefix": "khttpd-{{VERSION}}" +} diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index ca5d28e..ecd6cc7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -104,3 +104,52 @@ jobs: cd example bazel build app cd .. + + release: + needs: [build, example] + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from MODULE.bazel + id: version + run: | + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' MODULE.bazel | head -1) + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Found version: ${VERSION}" + + - name: Check if tag exists + id: check_tag + run: | + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag ${TAG} already exists, skipping release." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag ${TAG} does not exist, will create release." + fi + + - name: Create tag + if: steps.check_tag.outputs.exists == 'false' + run: | + TAG="v${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + - name: Create GitHub Release + if: steps.check_tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Release v${{ steps.version.outputs.version }} + generate_release_notes: true + draft: false diff --git a/.github/workflows/publish_to_bcr.yml b/.github/workflows/publish_to_bcr.yml new file mode 100644 index 0000000..5c00e00 --- /dev/null +++ b/.github/workflows/publish_to_bcr.yml @@ -0,0 +1,14 @@ +name: Publish to BCR + +on: + release: + types: [published] + +jobs: + publish: + uses: kekxv/bcr/.github/workflows/publish_to_bcr.yml@publish-to-bcr + with: + tag_name: ${{ github.event.release.tag_name }} + module_name: "khttpd" + secrets: + publish_token: ${{ secrets.BCR_PUBLISH_TOKEN }} diff --git a/MODULE.bazel b/MODULE.bazel index d23ad8e..d1c5610 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -18,7 +18,7 @@ single_version_override( ) bazel_dep(name = "fmt", version = "12.0.0") -bazel_dep(name = "googletest", version = "1.17.0.bcr.1") +bazel_dep(name = "googletest", version = "1.17.0.bcr.2") bazel_dep(name = "sqlite3", version = "3.50.4") bazel_dep(name = "openssl", version = "3.3.1.bcr.9") bazel_dep(name = "boringssl", version = "0.20251110.0") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index a455168..c0d94cb 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 24, + "lockFileVersion": 26, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -11,22 +11,33 @@ "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", - "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/source.json": "03c90ee57977264436d3231676dcddae116c4769a5d02b6fc16c2c9e019b583a", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", - "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", - "https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2", + "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", + "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", + "https://bcr.bazel.build/modules/apple_support/1.22.1/MODULE.bazel": "90bd1a660590f3ceffbdf524e37483094b29352d85317060b2327fff8f3f4458", + "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", + "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", @@ -38,6 +49,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", "https://bcr.bazel.build/modules/boost.algorithm/1.89.0.bcr.2/MODULE.bazel": "9226438a199b01a2dfa82325b03b6576df0b46e634f9d01770b84cdfe4fc3dcb", @@ -177,8 +189,8 @@ "https://bcr.bazel.build/modules/boringssl/0.20251002.0/MODULE.bazel": "d27433ae3dbb180193dffcd80aaa612bd0d63136f09629dd809a4c71ba114cdd", "https://bcr.bazel.build/modules/boringssl/0.20251110.0/MODULE.bazel": "a7472f6b886e838d09824534b56b44cd07022c903fd3f441cf3f19c1c4cfe2c3", "https://bcr.bazel.build/modules/boringssl/0.20251110.0/source.json": "b2821067608ea5c56a055d9259cb02c0a50f49eed0f4f1cda7d6b9a4c4293af9", - "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", - "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", + "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", "https://bcr.bazel.build/modules/fmt/12.0.0/MODULE.bazel": "5308b44200f97df17217c053367537c6d469fe46a61ab0dfc1038c04ceb1d735", "https://bcr.bazel.build/modules/fmt/12.0.0/source.json": "20a9d47908eaa8fd46ee7b2fbb0fd9ff02175addfdc1658817798c52604882c1", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", @@ -187,10 +199,15 @@ "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.1/MODULE.bazel": "9f8e815fba6e81dee850a33068166989000eabcf7690d2127a975c2ebda6baae", - "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.1/source.json": "7ec4d46613cc41d908cb87a58e7e7ad11dba4662640af8ae2200bd045c1e4f84", + "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.2/MODULE.bazel": "827f54f492a3ce549c940106d73de332c2b30cebd0c20c0bc5d786aba7f116cb", + "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.2/source.json": "3664514073a819992320ffbce5825e4238459df344d8b01748af2208f8d2e1eb", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.9/MODULE.bazel": "bf9dd8479c65bfec1c82773a5cc6ae06eda4c663c2731cfcfcb8b6b46ac8d365", "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.9/source.json": "c72e6b4db6b18e47a3050fbb3315ddf3353f55c81c2be7cba775856259edb7c5", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", @@ -204,26 +221,34 @@ "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a", + "https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12", "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", - "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", - "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", - "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", + "https://bcr.bazel.build/modules/pybind11_bazel/2.13.6/MODULE.bazel": "2d746fda559464b253b2b2e6073cb51643a2ac79009ca02100ebbc44b4548656", + "https://bcr.bazel.build/modules/pybind11_bazel/2.13.6/source.json": "6aa0703de8efb20cc897bbdbeb928582ee7beaf278bcd001ac253e1605bddfae", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", - "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", + "https://bcr.bazel.build/modules/re2/2025-08-12.bcr.1/MODULE.bazel": "e09b434b122bfb786a69179f9b325e35cb1856c3f56a7a81dd61609260ed46e1", + "https://bcr.bazel.build/modules/re2/2025-08-12.bcr.1/source.json": "a8ae7c09533bf67f9f6e5122d884d5741600b09d78dca6fc0f2f8d2ee0c2d957", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -232,36 +257,37 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.4/MODULE.bazel": "bb03a452a7527ac25a7518fb86a946ef63df860b9657d8323a0c50f8504fb0b9", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", - "https://bcr.bazel.build/modules/rules_cc/0.2.13/source.json": "f872e892c5265c5532e526857532f4868708f88d64e5ebe517ea72e09da61bdb", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/source.json": "64ccb6c4bff8afc336a24af2487b4557b8d2b13f981f2d8190983bc196b36a68", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", - "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", + "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", - "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", @@ -279,32 +305,45 @@ "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", + "https://bcr.bazel.build/modules/rules_python/0.34.0/MODULE.bazel": "1d623d026e075b78c9fde483a889cda7996f5da4f36dffb24c246ab30f06513a", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", "https://bcr.bazel.build/modules/rules_python/1.1.0/MODULE.bazel": "57e01abae22956eb96d891572490d20e07d983e0c065de0b2170cafe5053e788", - "https://bcr.bazel.build/modules/rules_python/1.1.0/source.json": "29f1fdfd23a40808c622f813bc93e29c3aae277333f03293f667e76159750a0f", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.5.1/MODULE.bazel": "acfe65880942d44a69129d4c5c3122d57baaf3edf58ae5a6bd4edea114906bf5", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.0/MODULE.bazel": "0f8f11bb3cd11755f0b48c1de0bbcf62b4b34421023aa41a2fc74ef68d9584f0", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/sqlite3/3.50.4/MODULE.bazel": "97e6be83033408655454db5fe12a2814e8f28d3cf031251b1de3ea3353363520", "https://bcr.bazel.build/modules/sqlite3/3.50.4/source.json": "26e2ca8a21b215b562fdf0a2d69e3f89c2ba74a6017138339cb9ddcdac39a5ea", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", @@ -314,11 +353,12 @@ "moduleExtensions": { "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { - "bzlTransitiveDigest": "214a15Hi6YO0SxdwD2rGG5hBYv7/aQ5blgNKDcASQaM=", + "bzlTransitiveDigest": "bTLENWEOzsR+6g/mQ/Ni27xVnSYd1Ziscsy+nQwfAqk=", "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_foreign_cc+,rules_foreign_cc rules_foreign_cc+" + ], "generatedRepoSpecs": { "rules_foreign_cc_framework_toolchain_linux": { "repoRuleId": "@@rules_foreign_cc+//foreign_cc/private/framework:toolchain.bzl%framework_toolchain_repository", @@ -681,28 +721,16 @@ "tool": "ninja" } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_foreign_cc+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_foreign_cc+", - "rules_foreign_cc", - "rules_foreign_cc+" - ] - ] + } } }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", + "bzlTransitiveDigest": "Ga4z8lQy1YQ5rAMy+dOl0dqcCEBnYNCXku8x3YQmDZI=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "com_github_jetbrains_kotlin_git": { "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", @@ -750,14 +778,205 @@ ] } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } + } + }, + "@@rules_python+//python/extensions:config.bzl%config": { + "general": { + "bzlTransitiveDigest": "iibnRYgg8LpcfmH7EAnVwYePC3jsVaJ6Id8XxUjSZps=", + "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], + "generatedRepoSpecs": { + "rules_python_internal": { + "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", + "attributes": { + "transition_setting_generators": {}, + "transition_settings": [] + } + }, + "pypi__build": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", + "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__click": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", + "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__colorama": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__importlib_metadata": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", + "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__installer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", + "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__more_itertools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", + "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__packaging": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", + "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pep517": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", + "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", + "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip_tools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", + "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pyproject_hooks": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", + "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__setuptools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", + "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__tomli": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", + "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__wheel": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__zipp": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", + "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + } + } + } + }, + "@@rules_python+//python/uv:uv.bzl%uv": { + "general": { + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], + "generatedRepoSpecs": { + "uv": { + "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", + "attributes": { + "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", + "toolchain_names": [ + "none" + ], + "toolchain_implementations": { + "none": "'@@rules_python+//python:none'" + }, + "toolchain_compatible_with": { + "none": [ + "@platforms//:incompatible" + ] + }, + "toolchain_target_settings": {} + } + } + } } } }, diff --git a/README.md b/README.md index dc2a9e9..fa2a495 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,315 @@ -# khttpd - a simple http server,power by boost - -## usage - -1. install `bazel` or `bazelisk` -2. mkdir workspace and add following content to `MODULE.bazel` - > ```MODULE.bazel - > http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") - > bazel_dep(name = "platforms", version = "0.0.11") - > bazel_dep(name = "rules_cc", version = "0.1.1") - > - > bazel_dep(name = "fmt", version = "11.0.2") - > bazel_dep(name = "googletest", version = "1.17.0") - > bazel_dep(name = "sqlite3", version = "3.49.1") - > bazel_dep(name = "boringssl", version = "0.20250415.0") - > bazel_dep(name = "boost", version = "1.88.0.bcr.2") - > bazel_dep(name = "boost.asio", version = "1.88.0.bcr.2") - > bazel_dep(name = "boost.mysql", version = "1.88.0.bcr.2") - > http_archive( - > name = "khttpd", - > integrity = "sha256-xrIHeqD3rtrxomQsHd0iWc4eKvlpjyOjW5dWOi/tEYc=", - > strip_prefix = "khttpd-0.0.1", - > url = "https://github.com/ClangTools/khttpd/archive/refs/tags/v0.0.1.tar.gz", - > ) - > ``` -3. create `BUILD.bazel`,like [BUILD.bazel](example/BUILD.bazel) -4. create you code like [example](example) -5. run `bazel build //:your_target_name` to compile -6. run `bazel run //:your_target_name` to execute - -## Exception Handling - -khttpd supports robust exception handling mechanisms. You can catch specific exceptions or handle all exceptions using `ExceptionDispatcher`. - -### Example +# khttpd + +A high-performance, header-only-style HTTP/WebSocket server framework built on top +of [Boost.Beast](https://www.boost.org/doc/libs/release/libs/beast/) +and [Boost.Asio](https://www.boost.org/doc/libs/release/libs/asio/), managed with [Bazel](https://bazel.build/). + +[文档](doc/index.md) + +## Features + +- **HTTP Server** — Multi-threaded, async I/O server powered by Boost.Asio strand-based concurrency +- **WebSocket Support** — Full WebSocket lifecycle management (onopen / onmessage / onclose / onerror) +- **Routing** — Express-style route registration with path parameters (`/users/:id`), query params, and method + specificity sorting +- **Controller Pattern** — CRTP-based `BaseController` with `KHTTPD_ROUTE` / `KHTTPD_WSROUTE` macros for clean route + definitions +- **HTTP Client** — Sync & async HTTP client with SSL, bearer token, base URL, and JSON body serialization +- **Oat++-style API Client** — Declarative API definition with `KHTTPD_API_CLIENT`, multi-host support with weight-based routing +- **WebSocket Client** — Async WebSocket client counterpart +- **Interceptors** — Pre-request / post-response middleware pipeline +- **Exception Handling** — Type-safe exception dispatcher with per-type handlers +- **Chunked Streaming** — Server-sent chunked transfer encoding via `HttpContext::chunked()` +- **Cookie Support** — Read / write cookies with configurable `CookieOptions` (path, domain, SameSite, etc.) +- **Form & Multipart** — `application/x-www-form-urlencoded` and `multipart/form-data` parsing (file uploads) +- **JSON** — Native `boost::json` integration with `get_json()`, `set_body_json()`, `set_body_from()` +- **Cron Scheduler** — Singleton-based cron task scheduler with cron expressions +- **Dependency Injection** — Type-indexed singleton DI container with constructor dependency resolution +- **Static Files** — Built-in static file serving with configurable web root +- **Signal Handling** — Graceful shutdown on SIGINT / SIGTERM + +## Tech Stack + +| Component | Version | +|---------------------|----------------| +| Boost | 1.89.0 | +| Boost.Beast | 1.89.0 | +| Boost.Asio | 1.89.0 | +| fmt | 12.0.0 | +| OpenSSL / BoringSSL | 3.3.1 / latest | +| SQLite3 | 3.50.4 | +| Build System | Bazel (bzlmod) | + +## Quick Start + +### 1. Add khttpd as a Bazel dependency + +In your project's `MODULE.bazel`: + +```python +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +bazel_dep(name="platforms", version="1.0.0") +bazel_dep(name="rules_cc", version="0.2.13") +bazel_dep(name="fmt", version="12.0.0") +bazel_dep(name="boost", version="1.89.0.bcr.2") +bazel_dep(name="boost.asio", version="1.89.0.bcr.2") +bazel_dep(name="boost.beast", version="1.89.0.bcr.2") +bazel_dep(name="boost.json", version="1.89.0.bcr.2") +bazel_dep(name="boost.filesystem", version="1.89.0.bcr.2") +bazel_dep(name="boost.url", version="1.89.0.bcr.2") +bazel_dep(name="boringssl", version="0.20251110.0") + +http_archive( + name="khttpd", + strip_prefix="khttpd-0.1.0", + url="https://github.com/ClangTools/khttpd/archive/refs/tags/v0.1.0.tar.gz", +) +``` + +### 2. Create your server ```cpp #include "framework/server.hpp" -#include "framework/exception/exception_handler.hpp" +#include "framework/context/http_context.hpp" +#include +#include -// ... inside your main function ... +namespace net = boost::asio; +using tcp = boost::asio::ip::tcp; -// Create a dispatcher -auto dispatcher = std::make_shared(); +int main() { + auto server = std::make_shared( + tcp::endpoint{net::ip::make_address("0.0.0.0"), 8080}, + "web_root", // static file root + std::thread::hardware_concurrency() // worker threads + ); -// Register handler for integer exceptions (e.g., throw 404;) -dispatcher->on([](const int& e, khttpd::framework::HttpContext& ctx) { - ctx.set_status(boost::beast::http::status::internal_server_error); - ctx.set_body("Internal Error Code: " + std::to_string(e)); + auto& router = server->get_http_router(); + + // Simple route + router.get("/hello", [](khttpd::framework::HttpContext& ctx) { + std::string name = ctx.get_query_param("name").value_or("World"); + ctx.set_status(boost::beast::http::status::ok); + ctx.set_content_type("text/plain"); + ctx.set_body(fmt::format("Hello, {}!", name)); + }); + + // JSON endpoint + router.post("/api/data", [](khttpd::framework::HttpContext& ctx) { + if (auto json = ctx.get_json()) { + ctx.set_body_from(*json); + } + }); + + // Path parameters + router.get("/users/:id", [](khttpd::framework::HttpContext& ctx) { + auto id = ctx.get_path_param("id").value_or("unknown"); + ctx.set_body(fmt::format("User: {}", id)); + }); + + server->run(); + return 0; +} +``` + +### 3. Build and run + +```bash +bazel build //:your_target +bazel run //:your_target +``` + +## Architecture + +``` +framework/ +├── server.hpp/cpp # Main server: acceptor, signal handling, thread pool +├── io_context_pool.hpp # Asio io_context thread pool +├── context/ +│ ├── http_context.hpp/cpp # Request/response abstraction (params, body, cookies, streaming) +│ └── websocket_context.hpp/cpp # WebSocket session context (send, attributes) +├── router/ +│ ├── http_router.hpp/cpp # Route matching, interceptors, exception dispatch +│ └── websocket_router.hpp/cpp # WS lifecycle handler registration +├── controller/ +│ └── http_controller.hpp # CRTP BaseController + KHTTPD_ROUTE / KHTTPD_WSROUTE macros +├── client/ +│ ├── http_client.hpp/cpp # Sync/async HTTP client with SSL +│ └── websocket_client.hpp/cpp # WebSocket client +├── interceptor/ +│ └── interceptor.hpp # Pre/Post middleware interface +├── exception/ +│ └── exception_handler.hpp # Type-safe exception dispatcher +├── cron/ +│ ├── CronJob.hpp # Cron job base class +│ ├── CronScheduler.hpp # Singleton scheduler +│ └── cronacci.hpp # Cron expression parser +├── di/ +│ └── di_container.hpp # Type-indexed DI container (singleton) +├── session/ +│ └── http_session.hpp/cpp # Per-connection HTTP session +└── websocket/ + └── websocket_session.hpp/cpp # Per-connection WebSocket session +``` + +## API Reference + +### HttpContext + +| Method | Description | +|--------------------------------|--------------------------------------------------| +| `path()` | Request path | +| `method()` | HTTP verb | +| `get_query_param(key)` | Query string parameter | +| `get_path_param(key)` | Path parameter (from `:param` routes) | +| `get_header(name)` | Request header | +| `get_cookie(key)` | Cookie value | +| `get_json()` | Parse body as `boost::json::value` | +| `get_form_param(key)` | Form field (`application/x-www-form-urlencoded`) | +| `get_multipart_field(key)` | Multipart text field | +| `get_uploaded_files(field)` | Uploaded files as `vector` | +| `set_status(code)` | Response status | +| `set_body(str)` | Response body | +| `set_body_json(obj)` | Serialize object to JSON response | +| `set_body_from(obj)` | `value_from` + JSON response | +| `set_content_type(type)` | Content-Type header | +| `set_header(name, value)` | Custom response header | +| `set_cookie(key, value, opts)` | Set response cookie | +| `chunked(handler)` | Enable chunked transfer streaming | +| `set_attribute(key, value)` | Store arbitrary data (for interceptors) | +| `get_attribute_as(key)` | Retrieve typed attribute | + +### WebSocket + +```cpp +auto& ws = server->get_websocket_router(); +ws.add_handler("/ws", + [](WebsocketContext& ctx) { /* onopen */ ctx.send("Welcome!"); }, + [](WebsocketContext& ctx) { /* onmessage */ ctx.send("Echo: " + ctx.message, ctx.is_text); }, + [](WebsocketContext& ctx) { /* onclose */ }, + [](WebsocketContext& ctx) { /* onerror */ } +); +``` + +### Controller Pattern + +```cpp +class MyController : public khttpd::framework::BaseController { + std::string base_path() override { return "/api"; } + + std::shared_ptr register_routes(HttpRouter& router) override { + KHTTPD_ROUTE(get, "/items", handle_list); + KHTTPD_ROUTE(get, "/items/:id", handle_get); + return shared_from_this(); + } + + void handle_list(HttpContext& ctx) { /* ... */ } + void handle_get(HttpContext& ctx) { /* ... */ } +}; + +// Register +MyController::create()->register_routes(server->get_http_router()); +``` + +### Interceptors + +```cpp +struct AuthInterceptor : khttpd::framework::Interceptor { + InterceptorResult handle_request(HttpContext& ctx) override { + if (!ctx.get_header("Authorization")) { + ctx.set_status(boost::beast::http::status::unauthorized); + ctx.set_body("Unauthorized"); + return InterceptorResult::Stop; + } + return InterceptorResult::Continue; + } +}; + +server->add_interceptor(std::make_shared()); +``` + +### Cron Scheduler + +```cpp +#include "framework/cron/CronScheduler.hpp" + +auto& scheduler = khttpd::framework::CronScheduler::instance(); +scheduler.schedule("0 */5 * * * *", []() { // every 5 minutes + fmt::print("Cron tick!\n"); }); +``` + +### HTTP Client + +```cpp +#include "framework/client/http_client.hpp" + +auto client = std::make_shared(); +client->set_base_url("https://api.example.com"); +client->set_bearer_token("your-token"); + +// Async +client->request(http::verb::get, "/users", {}, {}, {}, + [](beast::error_code ec, http::response res) { + if (!ec) fmt::print("{}\n", res.body()); + }); + +// Sync +auto res = client->request_sync(http::verb::post, "/data", {}, "{\"key\":\"val\"}", {}); +``` + +### Oat++-style API Client -// Register handler for standard exceptions -dispatcher->on([](const std::runtime_error& e, khttpd::framework::HttpContext& ctx) { +```cpp +#include "framework/client/api_macros.hpp" + +// 单 host +KHTTPD_API_CLIENT(GitHubClient, "https://api.github.com") + API_CALL(http::verb::get, "/users/:login", get_user, + PATH(std::string, login, "login")) +KHTTPD_API_CLIENT_END() + +// 多 host + 权重分发 +KHTTPD_API_CLIENT_POOL(GitHubClient, + KHTTPD_HOST("https://api.github.com", 3) + KHTTPD_HOST("https://api-backup.github.com", 1) +) + API_CALL(http::verb::get, "/users/:login", get_user, + PATH(std::string, login, "login")) +KHTTPD_API_CLIENT_END() + +// 使用 +auto gh = std::make_shared(); +auto res = gh->get_user_sync("octocat"); // 同步 +gh->get_user("octocat", [](auto ec, auto res) { /* 异步 */ }); +``` + +### Dependency Injection + +```cpp +#include "framework/di/di_container.hpp" + +auto& di = khttpd::framework::DI_Container::instance(); +di.register_component(); +di.register_component(); + +auto repo = di.resolve(); +``` + +### Exception Handling + +```cpp +#include "framework/exception/exception_handler.hpp" + +auto dispatcher = std::make_shared(); +dispatcher->on([](const std::runtime_error& e, HttpContext& ctx) { ctx.set_status(boost::beast::http::status::internal_server_error); - ctx.set_body(std::string("Runtime Error: ") + e.what()); + ctx.set_body(fmt::format("Error: {}", e.what())); }); +server->get_http_router().add_exception_handler(dispatcher); +``` -// Register handler for string literals -dispatcher->on([](const char* const& e, khttpd::framework::HttpContext& ctx) { - ctx.set_status(boost::beast::http::status::bad_request); - ctx.set_body(std::string("Error: ") + e); -}); +## License -// Add the dispatcher to the router -server.get_http_router().add_exception_handler(dispatcher); -``` \ No newline at end of file +MIT License — see [LICENSE](LICENSE) for details. diff --git a/doc/advanced.md b/doc/advanced.md new file mode 100644 index 0000000..5670965 --- /dev/null +++ b/doc/advanced.md @@ -0,0 +1,344 @@ +# 高级功能 + +## 拦截器(Interceptors) + +拦截器在请求到达路由处理**前**和响应生成**后**执行,支持链式组合。 + +### 实现拦截器 + +```cpp +struct AuthInterceptor : public khttpd::framework::Interceptor { + InterceptorResult handle_request(HttpContext& ctx) override { + auto auth = ctx.get_header("Authorization"); + if (!auth || auth->empty()) { + ctx.set_status(boost::beast::http::status::unauthorized); + ctx.set_body("Unauthorized"); + return InterceptorResult::Stop; + } + // 将用户信息存入上下文,供后续 handler 使用 + ctx.set_attribute("auth_token", *auth); + return InterceptorResult::Continue; + } + + void handle_response(HttpContext& ctx) override { + // 添加全局响应头 + ctx.set_header("X-Powered-By", "khttpd"); + } +}; +``` + +### 注册拦截器 + +```cpp +server->add_interceptor(std::make_shared()); +server->add_interceptor(std::make_shared()); +``` + +### 执行顺序 + +``` +Request → Interceptor1.handle_request → Interceptor2.handle_request → Handler + ← Interceptor2.handle_response ← Interceptor1.handle_response ← Response +``` + +- **前置拦截器**:按注册**正序**执行 +- **后置拦截器**:按注册**逆序**执行(洋葱模型) +- 任一前置返回 `Stop` → 跳过剩余前置和 handler → 执行全部后置 + +### 上下文数据传递 + +```cpp +// 前置拦截器 +ctx.set_attribute("user_id", std::string("user-456")); + +// 路由 handler +auto uid = ctx.get_attribute_as("user_id"); +// uid.value() == "user-456" +``` + +--- + +## 异常处理 + +### ExceptionDispatcher(推荐) + +```cpp +auto dispatcher = std::make_shared(); + +dispatcher->on([](const std::runtime_error& e, HttpContext& ctx) { + ctx.set_status(boost::beast::http::status::internal_server_error); + ctx.set_body(fmt::format("Server Error: {}", e.what())); +}); + +dispatcher->on([](const int code, HttpContext& ctx) { + // throw 404; 等整型异常 + ctx.set_status(static_cast(code)); +}); + +dispatcher->on([](const char* const msg, HttpContext& ctx) { + ctx.set_status(boost::beast::http::status::bad_request); + ctx.set_body(std::string("Error: ") + msg); +}); + +server->get_http_router().add_exception_handler(dispatcher); +``` + +### 自定义异常处理器 + +```cpp +class MyException : public std::exception { + std::string msg_; +public: + explicit MyException(std::string msg) : msg_(std::move(msg)) {} + const char* what() const noexcept override { return msg_.c_str(); } +}; + +class MyExceptionHandler : public khttpd::framework::ExceptionHandler { + void handle(const MyException& e, HttpContext& ctx) override { + ctx.set_status(boost::beast::http::status::unprocessable_entity); + ctx.set_body(e.what()); + } +}; + +router.add_exception_handler(std::make_shared()); +``` + +### 未知异常兜底 + +```cpp +router.set_unknown_exception_handler([](HttpContext& ctx) { + ctx.set_status(boost::beast::http::status::service_unavailable); + ctx.set_body("An unexpected error occurred"); +}); +``` + +--- + +## WebSocket + +### 基本用法 + +```cpp +auto& ws_router = server->get_websocket_router(); + +ws_router.add_handler( + "/chat", + // on_open + [](WebsocketContext& ctx) { + ctx.send("Welcome to the chat!"); + }, + // on_message + [](WebsocketContext& ctx) { + ctx.send("Echo: " + ctx.message, ctx.is_text); + }, + // on_close + [](WebsocketContext& ctx) { + // 清理资源 + }, + // on_error + [](WebsocketContext& ctx) { + fmt::print(stderr, "WS Error: {}\n", ctx.error_code.message()); + } +); +``` + +### 广播消息 + +```cpp +// 向指定 session 发送消息 +WebsocketSession::send_message(session_id, "Hello!", true); + +// 批量发送 +std::vector ids = {"id1", "id2", "id3"}; +WebsocketSession::send_message(ids, "Broadcast message", true); +``` + +### Controller 方式注册 + +```cpp +class ChatController : public khttpd::framework::BaseController { + std::string base_path() override { return "/chat"; } + + std::shared_ptr register_routes(HttpRouter& router) override { + KHTTPD_ROUTE(get, "", handle_upgrade_hint); + return shared_from_this(); + } + + std::shared_ptr register_routes(WebsocketRouter& router) override { + KHTTPD_WSROUTE("", on_open, on_message, on_close, on_error); + return shared_from_this(); + } + + void handle_upgrade_hint(HttpContext& ctx) { + ctx.set_status(boost::beast::http::status::upgrade_required); + ctx.set_header(boost::beast::http::field::upgrade, "websocket"); + ctx.set_body("WebSocket endpoint"); + } + + void on_open(WebsocketContext& ctx) { /* ... */ } + void on_message(WebsocketContext& ctx) { /* ... */ } + void on_close(WebsocketContext& ctx) { /* ... */ } + void on_error(WebsocketContext& ctx) { /* ... */ } +}; + +ChatController::create()->register_routes(ws_router); +``` + +--- + +## 分块流式响应 + +```cpp +router.get("/stream/:count", [](HttpContext& ctx) { + int count = std::stoi(ctx.get_path_param("count").value_or("10")); + + auto stream = [count](HttpContext& ctx, const WriteHandler& write) { + for (int i = 0; i < count; i++) { + auto chunk = fmt::format("Chunk {}\n", i); + if (!write(chunk)) break; // 客户端断开时 write 返回 false + } + }; + + ctx.chunked(stream); +}); +``` + +### 写入控制 + +`WriteHandler` 返回 `false` 时停止写入(客户端已断开)。 + +--- + +## Cron 定时任务 + +### Lambda 任务 + +```cpp +auto& scheduler = khttpd::framework::CronScheduler::instance(); + +// 每分钟执行一次 +scheduler.schedule("* * * * * *", []() { + fmt::print("Cron tick: {}\n", std::time(nullptr)); +}); + +// 每 5 分钟,延迟 10 秒启动 +scheduler.schedule("0 */5 * * * *", []() { + // 清理过期 session +}, std::chrono::milliseconds(10000)); +``` + +### 继承式任务 + +```cpp +class CleanupJob : public khttpd::framework::CronJob { +public: + CleanupJob() : CronJob("0 0 * * * *") {} // 每天午夜 + +protected: + void run() override { + // 执行清理逻辑 + } +}; + +auto job = std::make_shared(); +job->start(); +``` + +### Cron 表达式(6 字段:秒 分 时 日 月 周) + +| 表达式 | 说明 | +|--------|------| +| `* * * * * *` | 每秒 | +| `0 * * * * *` | 每分钟 | +| `0 */5 * * * *` | 每 5 分钟 | +| `0 0 * * * *` | 每小时 | +| `0 0 9 * * *` | 每天 9:00 | +| `0 0 9 * * 1-5` | 工作日 9:00 | + +--- + +## 依赖注入(DI Container) + +### 注册与解析 + +```cpp +auto& di = khttpd::framework::DI_Container::instance(); + +// 无依赖组件 +di.register_component(); + +// 有依赖组件(自动注入构造) +di.register_component(); +di.register_component(); + +// 解析(单例) +auto userService = di.resolve(); +``` + +### 嵌套依赖 + +```cpp +// A → B → C +di.register_component(); +di.register_component(); +di.register_component(); + +auto a = di.resolve(); // 自动解析 BService → CService +``` + +### 组件必须继承 ComponentBase + +```cpp +class MyService : public khttpd::framework::ComponentBase { +public: + explicit MyService(std::shared_ptr dep) : dep_(dep) {} + void do_something() { /* ... */ } +private: + std::shared_ptr dep_; +}; +``` + +### 循环依赖检测 + +```cpp +di.register_component(); +di.register_component(); + +// 抛出 std::runtime_error: "Circular dependency detected" +di.resolve(); +``` + +--- + +## Cookie 操作 + +### 读取 Cookie + +```cpp +auto session_id = ctx.get_cookie("session_id"); // std::optional +auto all_users = ctx.get_cookies("user"); // std::vector +``` + +### 设置 Cookie + +```cpp +// 简单 cookie +ctx.set_cookie("foo", "bar"); + +// 完整选项 +CookieOptions opts; +opts.max_age = 3600; // 1 小时 +opts.path = "/api"; +opts.domain = "example.com"; +opts.secure = true; // 仅 HTTPS +opts.http_only = true; // 禁止 JS 访问 +opts.same_site = "Strict"; +ctx.set_cookie("user", "123", opts); + +// 删除 cookie(max_age = 0) +CookieOptions delete_opts; +delete_opts.max_age = 0; +ctx.set_cookie("user", "", delete_opts); +``` + +> **注意**: Cookie 的 key 和 value 不能包含 `;`, `,`, `\r`, `\n`。key 还不能包含 `=`。设置包含这些字符的 cookie 会被拒绝。 diff --git a/doc/api-reference.md b/doc/api-reference.md new file mode 100644 index 0000000..f8e39f7 --- /dev/null +++ b/doc/api-reference.md @@ -0,0 +1,424 @@ +# API 参考文档 + +## Server + +### 构造函数 + +```cpp +Server(const tcp::endpoint& endpoint, std::string web_root, int num_threads = 1); +``` + +| 参数 | 类型 | 说明 | +|------|------|------| +| `endpoint` | `tcp::endpoint` | 监听地址和端口,如 `tcp::endpoint{ip::make_address("0.0.0.0"), 8080}` | +| `web_root` | `std::string` | 静态文件根目录路径。服务会自动将 `/` 下的文件作为静态资源提供 | +| `num_threads` | `int` | 工作线程数,默认 1。推荐设为 `std::thread::hardware_concurrency()` | + +### 方法 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `get_http_router()` | `HttpRouter&` | 获取 HTTP 路由器引用,用于注册路由 | +| `get_websocket_router()` | `WebsocketRouter&` | 获取 WebSocket 路由器引用 | +| `add_interceptor(interceptor)` | `void` | 添加全局请求/响应拦截器 | +| `run()` | `void` | 启动服务器(阻塞调用,直到收到 SIGINT/SIGTERM) | +| `stop()` | `void` | 停止服务器,关闭 acceptor 和线程池 | + +### 示例 + +```cpp +auto server = std::make_shared( + tcp::endpoint{net::ip::make_address("0.0.0.0"), 8080}, + "web_root", + std::thread::hardware_concurrency() +); +server->run(); +``` + +--- + +## HttpContext + +请求与响应的统一上下文对象。 + +### 请求信息 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `path()` | `const std::string&` | 请求路径(不含查询字符串) | +| `method()` | `http::verb` | HTTP 方法 | +| `body()` | `std::string` | 请求体 | +| `get_request()` | `Request&` | 原始 Beast 请求对象 | + +### 参数提取 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `get_query_param(key)` | `std::optional` | 查询字符串参数,如 `?name=value` | +| `get_path_param(key)` | `std::optional` | 路径参数,如 `/users/:id` 中的 `id` | +| `get_header(name)` | `std::optional` | 请求头(支持 `http::field` 枚举和字符串) | +| `get_headers(name)` | `std::optional>` | 同名请求头列表 | + +### Cookie 操作 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `get_cookie(key)` | `std::optional` | 获取单个 cookie 值 | +| `get_cookies(key)` | `std::vector` | 获取同名 cookie 列表 | +| `set_cookie(key, value, options)` | `void` | 设置响应 cookie。`options` 为 `CookieOptions` 结构体 | + +`CookieOptions` 字段: + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `max_age` | `int` | `-1` | 存活秒数。`-1`=会话 cookie,`0`=删除 cookie | +| `path` | `std::string` | `"/"` | 路径 | +| `domain` | `std::string` | `""` | 域名 | +| `secure` | `bool` | `false` | 仅 HTTPS 传输 | +| `http_only` | `bool` | `true` | 禁止 JavaScript 访问 | +| `same_site` | `std::string` | `"Lax"` | `Strict`, `Lax`, `None` | + +### JSON 解析 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `get_json()` | `std::optional` | 解析请求体为 JSON(自动检查 Content-Type) | + +### 表单与文件上传 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `get_form_param(key)` | `std::optional` | 获取 `application/x-www-form-urlencoded` 表单字段 | +| `get_multipart_field(key)` | `std::optional` | 获取 `multipart/form-data` 文本字段 | +| `get_uploaded_files(field)` | `const std::vector*` | 获取上传的文件列表,`nullptr` 表示字段不存在 | + +`MultipartFile` 结构体: + +```cpp +struct MultipartFile { + std::string filename; // 文件名 + std::string content_type; // MIME 类型 + std::string data; // 文件内容 +}; +``` + +### 响应设置 + +| 方法 | 说明 | +|------|------| +| `set_status(status)` | 设置 HTTP 状态码 | +| `set_body(str)` | 设置响应体 | +| `set_body_json(obj, opts)` | 序列化对象为 JSON 响应体,自动设置 `Content-Type: application/json` | +| `set_body_from(obj, sp, opts)` | 使用 `boost::json::value_from` 序列化响应体 | +| `set_header(name, value)` | 设置响应头 | +| `set_content_type(type)` | 设置 Content-Type | + +### 分块流式传输 + +| 方法 | 说明 | +|------|------| +| `chunked(handler)` | 启用 chunked transfer encoding。`handler` 签名:`void(HttpContext&, const WriteHandler&)` | + +`WriteHandler` 签名:`bool(const std::string& buffer)` — 写入成功返回 `true`,写入失败或连接断开返回 `false`。 + +### 扩展数据(拦截器间传递) + +| 方法 | 说明 | +|------|------| +| `set_attribute(key, value)` | 存储任意类型数据(`std::any`) | +| `get_attribute(key)` | 获取 `std::any` 值 | +| `get_attribute_as(key)` | 获取并类型转换为 `std::optional` | + +--- + +## HttpRouter + +### 路由注册 + +| 方法 | 说明 | +|------|------| +| `get(path, handler)` | 注册 GET 路由 | +| `post(path, handler)` | 注册 POST 路由 | +| `put(path, handler)` | 注册 PUT 路由 | +| `del(path, handler)` | 注册 DELETE 路由 | +| `options(path, handler)` | 注册 OPTIONS 路由 | + +`handler` 签名:`void(HttpContext&)` + +### 路由语法 + +| 语法 | 示例 | 匹配 | +|------|------|------| +| 静态路径 | `/api/users` | 精确匹配 | +| 动态参数 | `/users/:id` | 匹配单段路径,如 `/users/123` | +| 尾部通配 | `/files/:filepath` | 最后一个参数匹配剩余所有路径段 | + +### 路由优先级 + +当多个路由同时匹配时,按以下规则排序: +1. 字面路径段数量多的优先 +2. 字面路径段数量相同时,动态参数少的优先 + +例如:`/users/profile` 优先于 `/users/:id` + +### 拦截器与异常处理 + +| 方法 | 说明 | +|------|------| +| `add_interceptor(interceptor)` | 添加拦截器 | +| `add_exception_handler(handler)` | 添加异常处理器 | +| `set_unknown_exception_handler(handler)` | 设置未知异常兜底处理器 | +| `run_pre_interceptors(ctx)` | 执行前置拦截器 | +| `run_post_interceptors(ctx)` | 执行后置拦截器(逆序) | +| `handle_exception(eptr, ctx)` | 分发异常到注册的处理器 | +| `dispatch(ctx, static_file_fun)` | 路由分发 | + +--- + +## WebsocketRouter + +### 类型定义 + +```cpp +using WebsocketOpenHandler = std::function; +using WebsocketMessageHandler = std::function; +using WebsocketCloseHandler = std::function; +using WebsocketErrorHandler = std::function; +``` + +### 方法 + +| 方法 | 说明 | +|------|------| +| `add_handler(path, on_open, on_message, on_close, on_error)` | 注册 WebSocket 路径的所有生命周期处理器。`path` 为精确匹配(不支持动态参数) | +| `dispatch_open(path, ctx)` | 分发 open 事件 | +| `dispatch_message(path, ctx)` | 分发 message 事件 | +| `dispatch_close(path, ctx)` | 分发 close 事件 | +| `dispatch_error(path, ctx)` | 分发 error 事件 | + +--- + +## WebsocketContext + +### 公共字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `std::string` | 连接唯一标识 | +| `message` | `std::string` | 接收到的消息内容(仅 message 事件有效) | +| `is_text` | `bool` | 消息是否为文本(仅 message 事件有效) | +| `error_code` | `beast::error_code` | 错误码(仅 error/close 事件有效) | +| `path` | `std::string` | 连接路径 | +| `session_weak_ptr` | `weak_ptr` | 会话的弱引用 | + +### 方法 + +| 方法 | 说明 | +|------|------| +| `send(msg, is_text)` | 发送消息给客户端 | +| `set_attribute(key, value)` | 存储扩展数据 | +| `get_attribute_as(key)` | 获取并类型转换扩展数据 | + +--- + +## BaseController + +### 类定义 + +```cpp +template +class BaseController : public std::enable_shared_from_this +``` + +### 虚函数 + +| 方法 | 默认实现 | 说明 | +|------|----------|------| +| `base_path()` | `""` | 重写以设置路由前缀 | +| `register_routes(HttpRouter&)` | 纯虚 | 注册 HTTP 路由 | +| `register_routes(WebsocketRouter&)` | 空实现 | 注册 WebSocket 路由 | + +### 辅助函数 + +| 方法 | 说明 | +|------|------| +| `bind_handler(&Class::method)` | 将成员函数绑定为路由处理器 | + +### 路由宏 + +```cpp +KHTTPD_ROUTE(verb, path, method_name) // HTTP 路由 +KHTTPD_WSROUTE(path, ...) // WebSocket 路由(2-5 个处理器参数) +``` + +--- + +## Interceptor + +### 枚举 + +```cpp +enum class InterceptorResult { Continue, Stop }; +``` + +### 虚函数 + +| 方法 | 默认返回 | 调用时机 | +|------|----------|----------| +| `handle_request(ctx)` | `Continue` | 路由处理前,按添加顺序执行 | +| `handle_response(ctx)` | 空 | 响应生成后,按添加**逆序**执行 | + +返回 `Stop` 时中断后续拦截器和路由处理器,直接执行后置拦截器。 + +--- + +## Exception Handling + +### ExceptionDispatcher + +```cpp +class ExceptionDispatcher : public ExceptionHandlerBase +{ +public: + template + void on(std::function handler); +}; +``` + +注册多种异常类型的处理器,按注册顺序匹配。 + +### ExceptionHandler<E> + +```cpp +template +class ExceptionHandler : public ExceptionHandlerBase +{ + virtual void handle(const E& e, HttpContext& ctx) = 0; +}; +``` + +针对单一异常类型的处理器(需继承实现)。 + +--- + +## DI Container + +### 单例访问 + +```cpp +auto& container = khttpd::framework::DI_Container::instance(); +``` + +### 方法 + +| 方法 | 说明 | +|------|------| +| `register_component()` | 注册组件 `T`,依赖 `Args...`(自动注入构造) | +| `resolve()` | 解析组件实例(单例) | +| `clear()` | 清空所有注册和缓存 | + +### 示例 + +```cpp +auto& di = DI_Container::instance(); +di.register_component(); +di.register_component(); +auto repo = di.resolve(); +``` + +--- + +## Cron Scheduler + +### 单例访问 + +```cpp +auto& scheduler = khttpd::framework::CronScheduler::instance(); +``` + +### 方法 + +| 方法 | 说明 | +|------|------| +| `schedule(expression, task, delay)` | 调度定时任务。返回 `shared_ptr` 句柄 | + +`expression` 为 6 字段 cron 表达式(秒 分 时 日 月 周),如 `"0 */5 * * * *"`(每 5 分钟)。 + +### CronJob + +```cpp +class CronJob { +public: + void start(delay_ms); // 启动任务,可选延迟 + void stop(); // 停止任务 + bool is_running(); // 是否运行中 +}; +``` + +--- + +## HttpClient + +### 构造函数 + +| 构造函数 | 说明 | +|----------|------| +| `HttpClient()` | 使用全局 IO 池 + 默认 SSL | +| `HttpClient(ssl::context&)` | 全局 IO + 自定义 SSL | +| `HttpClient(io_context&)` | 自定义 IO + 默认 SSL | +| `HttpClient(io_context&, ssl::context&)` | 完全自定义 | + +### 配置 + +| 方法 | 说明 | +|------|------| +| `set_base_url(url)` | 设置基础 URL | +| `set_default_header(key, value)` | 设置默认请求头 | +| `set_bearer_token(token)` | 设置 Bearer Token 认证 | +| `set_timeout(seconds)` | 设置超时时间 | + +### 请求 + +| 方法 | 说明 | +|------|------| +| `request(method, path, query_params, body, headers, callback)` | 异步请求 | +| `request_sync(method, path, query_params, body, headers)` | 同步请求 | + +### API_CALL 宏 + +```cpp +// 异步 + 同步方法自动生成 +API_CALL(http::verb::get, "/users/:id", get_user, + PATH(std::string, id), + QUERY(std::string, filter, "filter"), + HEADER(std::string, token, "Authorization")) +``` + +生成方法: +- `get_user(id, filter, token, callback)` — 异步 +- `get_user_sync(id, filter, token)` — 同步 + +参数标签:`QUERY(Type, Name, Key)`, `PATH(Type, Name)`, `BODY(Type, Name)`, `HEADER(Type, Name, Key)` + +--- + +## WebsocketClient + +### 构造函数 + +| 构造函数 | 说明 | +|----------|------| +| `WebsocketClient()` | 默认 SSL | +| `WebsocketClient(io_context&)` | 指定 IO 上下文 | + +### 方法 + +| 方法 | 说明 | +|------|------| +| `connect(url, callback)` | 连接 WebSocket(支持 `ws://` 和 `wss://`) | +| `send(message)` | 发送消息(线程安全) | +| `close()` | 关闭连接 | +| `set_header(key, value)` | 设置握手头 | +| `set_on_message(handler)` | 设置消息回调 | +| `set_on_error(handler)` | 设置错误回调 | +| `set_on_close(handler)` | 设置关闭回调 | diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..87742b3 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,187 @@ +# 架构指南 + +## 概览 + +khttpd 是基于 Boost.Beast 和 Boost.Asio 构建的 C++ HTTP/WebSocket 服务器框架,采用 Bazel 构建系统。框架设计目标:简洁的路由 API、异步 I/O 高并发、完整的中间件与异常处理支持。 + +## 核心架构图 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Server │ +│ ┌──────────────────┐ ┌────────────────────────────┐ │ +│ │ HttpRouter │ │ WebsocketRouter │ │ +│ │ - 路由匹配 │ │ - 路径匹配 │ │ +│ │ - 拦截器链 │ │ - 生命周期分发 │ │ +│ │ - 异常分发 │ │ │ │ +│ └────────┬─────────┘ └─────────────┬──────────────┘ │ +│ │ │ │ +│ ┌────────┴─────────┐ ┌─────────────┴──────────────┐ │ +│ │ HttpSession │◄──┤ WebSocket Session │ │ +│ │ - HTTP 请求解析 │ │ - WS 握手 │ │ +│ │ - 静态文件服务 │ │ - 消息收发 │ │ +│ │ - 响应序列化 │ │ - 广播发送 │ │ +│ └────────┬─────────┘ └────────────────────────────┘ │ +│ │ │ +│ ┌────────┴─────────┐ │ +│ │ HttpContext │ WebsocketContext │ +│ │ - 请求/响应封装 │ - 消息/连接状态 │ +│ │ - 参数提取 │ - 扩展属性 │ +│ │ - Cookie/JSON │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ │ +┌────────┴─────────┐ ┌───────────┴──────────┐ +│ IoContextPool │ │ CronScheduler │ +│ - 线程池管理 │ │ DI Container │ +│ - io_context │ │ Exception Handler │ +└──────────────────┘ │ Interceptor │ + │ HttpClient/WS │ + └──────────────────────┘ +``` + +## 请求处理流程 + +``` +Client Request + │ + ▼ +┌─────────────┐ +│ Acceptor │ 接受 TCP 连接,分配到 Strand +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ HttpSession │ async_read 读取 HTTP 请求 +└──────┬──────┘ + │ + ▼ +┌──────────────────┐ +│ Pre-Interceptor │ 链式执行,可中断 +│ Chain │ 任一返回 Stop → 跳到 Post-Interceptor +└──────┬───────────┘ + │ Continue + ▼ +┌─────────────┐ +│ Router │ 按优先级匹配路由 +│ Dispatch │ 执行 handler +└──────┬──────┘ + │ + ▼ +┌──────────────────┐ +│ Post-Interceptor │ 逆序执行 +│ Chain │ +└──────┬───────────┘ + │ + ▼ +┌─────────────┐ +│ Response │ async_write 发送响应 +│ (send) │ 支持 keep-alive 多请求 +└─────────────┘ +``` + +## WebSocket 升级流程 + +``` +HTTP Request (Upgrade header) + │ + ▼ +┌─────────────┐ +│ HttpSession │ 检测 is_upgrade() +│ on_read │ +└──────┬──────┘ + │ + ▼ +┌─────────────────────┐ +│ WebSocket Session │ 释放 socket,创建 WS 会话 +│ run_handshake │ async_accept 完成握手 +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ dispatch_open │ 触发 on_open 回调 +│ │ 注册到全局 session 注册表 +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ do_read loop │ async_read → dispatch_message +│ │ 消息循环直到连接关闭 +└─────────────────────┘ +``` + +## 关键设计决策 + +### 1. 线程模型 + +- 使用 **Boost.Asio strand** 保证单个连接的串行化 +- 多个 strand 分配到线程池 (`IoContextPool`) 实现并发 +- **不需要** 在 handler 中加锁(同一连接的 handler 在 strand 上串行执行) + +### 2. 路由匹配 + +- 使用 **正则表达式** 解析动态路径参数 +- 按**特异性排序**:字面段多的路由优先,同数量下动态段少的优先 +- WebSocket 路由器仅支持精确路径匹配(不支持动态参数) + +### 3. 响应发送 + +- 普通响应:`beast::async_write` 异步发送 +- Chunked 流式响应:通过 `HttpContext::chunked()` 启用,内部将同步写入转换为异步写链 +- 静态文件:使用 Beast 的 `file_body` 零拷贝发送 + +### 4. 内存管理 + +- `HttpSession` / `WebsocketSession` 由 `shared_ptr` 管理 +- `HttpContext` / `WebsocketContext` 为临时栈对象,生命周期仅限于单个请求/事件 +- 拦截器和异常处理器存储为 `shared_ptr` 在路由器中 + +### 5. 静态文件安全 + +- 使用 `boost::filesystem::canonical()` 规范化路径,防止 `../` 目录遍历 +- 规范化后校验路径仍在 `web_root` 内 +- 目录请求自动尝试 `index.html` + +## 扩展点 + +| 扩展方式 | 说明 | +|----------|------| +| **拦截器** | 实现 `Interceptor` 接口,注册到 `HttpRouter` | +| **异常处理器** | 继承 `ExceptionHandler` 或使用 `ExceptionDispatcher` | +| **Controller** | 继承 `BaseController`,用 `KHTTPD_ROUTE` 注册路由 | +| **Cron 任务** | 调用 `CronScheduler::instance().schedule()` | +| **DI 组件** | 继承 `ComponentBase`,注册到 `DI_Container` | + +## 目录结构 + +``` +framework/ +├── server.hpp/cpp # 主服务器:acceptor、信号处理 +├── io_context_pool.hpp # IO 线程池(单例) +├── context/ +│ ├── http_context.hpp/cpp # HTTP 请求/响应上下文 +│ └── websocket_context.hpp/cpp # WebSocket 上下文 +├── router/ +│ ├── http_router.hpp/cpp # HTTP 路由匹配与分发 +│ └── websocket_router.hpp/cpp # WebSocket 路由与生命周期 +├── controller/ +│ └── http_controller.hpp # CRTP Controller + 路由宏 +├── client/ +│ ├── http_client.hpp/cpp # HTTP 客户端(同步/异步) +│ ├── websocket_client.hpp/cpp # WebSocket 客户端 +│ └── macros.hpp # API_CALL 宏 +├── interceptor/ +│ └── interceptor.hpp # 拦截器接口 +├── exception/ +│ └── exception_handler.hpp # 异常处理器 +├── cron/ +│ ├── CronJob.hpp # Cron 任务基类 +│ ├── CronScheduler.hpp # Cron 调度器(单例) +│ └── cronacci.hpp # Cron 表达式解析 +├── di/ +│ └── di_container.hpp # 依赖注入容器(单例) +├── session/ +│ └── http_session.hpp/cpp # HTTP 连接会话 +└── websocket/ + └── websocket_session.hpp/cpp # WebSocket 连接会话 +``` diff --git a/doc/http-client.md b/doc/http-client.md new file mode 100644 index 0000000..cee296d --- /dev/null +++ b/doc/http-client.md @@ -0,0 +1,273 @@ +# HTTP 与 WebSocket 客户端 + +khttpd 内置 HTTP 和 WebSocket 客户端,方便在同一个项目中同时提供服务端和客户端能力。 + +## HTTP 客户端 + +### 基本使用 + +```cpp +#include "framework/client/http_client.hpp" + +namespace net = boost::asio; + +// 方式 1: 使用全局 IO 池(推荐,最简单) +auto client = std::make_shared(); + +// 方式 2: 指定 IO 上下文 +net::io_context ioc; +auto client = std::make_shared(ioc); +``` + +### 配置 + +```cpp +client->set_base_url("https://api.example.com"); +client->set_bearer_token("your-jwt-token"); +client->set_default_header("X-App-Version", "1.0.0"); +client->set_timeout(std::chrono::seconds(30)); +``` + +### 同步请求 + +```cpp +try { + auto res = client->request_sync( + http::verb::get, + "/users", + {{"page", "1"}, {"limit", "20"}}, // 查询参数 + "", // 请求体 + {} // 额外请求头 + ); + + fmt::print("Status: {}\n", res.result()); + fmt::print("Body: {}\n", res.body()); +} catch (const std::exception& e) { + fmt::print(stderr, "Request failed: {}\n", e.what()); +} +``` + +### 异步请求 + +```cpp +client->request( + http::verb::post, + "/users", + {}, + R"({"name":"Alice","email":"alice@example.com"})", + {{"Content-Type", "application/json"}}, + [](beast::error_code ec, http::response res) { + if (!ec) { + fmt::print("Response: {}\n", res.body()); + } else { + fmt::print(stderr, "Error: {}\n", ec.message()); + } + } +); +``` + +### API_CALL 宏(自动生成客户端方法) + +在类中定义 API 方法,自动生成异步和同步版本: + +```cpp +class GitHubClient : public HttpClient { +public: + GitHubClient() { + set_base_url("https://api.github.com"); + } + + // 生成: get_user(username, callback) 和 get_user_sync(username) + API_CALL(http::verb::get, "/users/:login", get_user, + PATH(std::string, login, "login")) + + // 生成: list_repos(username, page, per_page, callback) 和同步版本 + API_CALL(http::verb::get, "/users/:login/repos", list_repos, + PATH(std::string, login, "login"), + QUERY(int, page, "page"), + QUERY(int, per_page, "per_page")) + + // 生成: create_repo(body, callback) 和同步版本 + API_CALL(http::verb::post, "/user/repos", create_repo, + BODY(boost::json::object, repo_data)) + + // 生成: get_repo(login, repo_name, callback) + API_CALL(http::verb::get, "/repos/:login/:repo", get_repo, + PATH(std::string, login, "login"), + PATH(std::string, repo, "repo")) +}; +``` + +使用方式: + +```cpp +auto gh = std::make_shared(); + +// 同步 +auto res = gh->get_user_sync("octocat"); + +// 异步 +gh->get_repo("octocat", "Hello-World", [](auto ec, auto res) { + if (!ec) { + fmt::print("Stars: {}\n", res.body()); + } +}); +``` + +### 参数标签 + +| 标签 | 用途 | 示例 | +|------|------|------| +| `PATH(Type, Name, Key)` | 路径参数,替换 URL 中的 `:key` | `PATH(std::string, id, "id")` | +| `QUERY(Type, Name, Key)` | 查询字符串参数 | `QUERY(int, page, "page")` | +| `HEADER(Type, Name, Key)` | 自定义请求头 | `HEADER(std::string, token, "Authorization")` | +| `BODY(Type, Name)` | 请求体(自动序列化为 JSON) | `BODY(boost::json::object, data)` | + +### Oat++ 风格 API 定义 + +使用 `KHTTPD_API_CLIENT` 或 `KHTTPD_API_CLIENT_POOL` 宏以类 Oat++ 的声明式风格定义客户端: + +```cpp +// 单 host 客户端 +KHTTPD_API_CLIENT(GitHubClient, "https://api.github.com") + API_CALL(http::verb::get, "/users/:login", get_user, + PATH(std::string, login, "login")) + API_CALL(http::verb::get, "/users/:login/repos", list_repos, + PATH(std::string, login, "login"), + QUERY(int, page, "page")) +KHTTPD_API_CLIENT_END() +``` + +在类体内部可以直接使用 `API_CALL` 宏,自动生成: +- `get_user(login, callback)` — 异步方法 +- `get_user_sync(login)` — 同步方法 + +### 多 Host + 权重分发 + +使用 `KHTTPD_API_CLIENT_POOL` 定义多 host 客户端,请求按权重分配到不同后端: + +```cpp +KHTTPD_API_CLIENT_POOL(GitHubClient, + KHTTPD_HOST("https://api.github.com", 3) // 权重 3 (60%) + KHTTPD_HOST("https://api-backup.github.com", 2) // 权重 2 (40%) +) + API_CALL(http::verb::get, "/users/:login", get_user, + PATH(std::string, login, "login")) +KHTTPD_API_CLIENT_END() +``` + +每次请求时,客户端会按权重随机选择一个 host 发起请求。 + +### 宏参考 + +| 宏 | 说明 | +|----|------| +| `KHTTPD_API_CLIENT(Name, Host)` | 定义继承自 `HttpClient` 的类,自动设置单 host 基础 URL | +| `KHTTPD_API_CLIENT_POOL(Name, ...)` | 定义继承自 `HttpClient` 的类,使用多 host 池 + 权重分发 | +| `KHTTPD_HOST(Url, Weight)` | Host 池配置项,指定 URL 和权重 | +| `KHTTPD_API_CLIENT_END()` | 结束类定义 | +| `API_CALL(METHOD, PATH, NAME, ...)` | 在类体内使用,生成异步 + 同步 API 方法 | +| `verb_from_string("GET")` | 将字符串转换为 `http::verb` 枚举值 | + +--- + +## WebSocket 客户端 + +### 基本使用 + +```cpp +#include "framework/client/websocket_client.hpp" + +namespace net = boost::asio; + +auto ws = std::make_shared(); + +// 设置回调 +ws->set_on_message([](const std::string& msg) { + fmt::print("Received: {}\n", msg); +}); + +ws->set_on_error([](beast::error_code ec) { + if (ec != boost::asio::error::operation_aborted) { + fmt::print(stderr, "WS Error: {}\n", ec.message()); + } +}); + +ws->set_on_close([]() { + fmt::print("Connection closed\n"); +}); + +// 连接 +ws->connect("wss://echo.websocket.org", [](beast::error_code ec) { + if (!ec) { + fmt::print("Connected!\n"); + } +}); +``` + +### 发送消息 + +```cpp +// 发送文本消息(线程安全) +ws->send("Hello, server!"); + +// 发送多条消息(自动排队) +ws->send("Message 1"); +ws->send("Message 2"); +ws->send("Message 3"); +``` + +### 完整示例:Echo 客户端 + +```cpp +class EchoClient { +public: + EchoClient(net::io_context& ioc) : ws_(std::make_shared(ioc)) { + ws_->set_on_message([this](const std::string& msg) { + fmt::print("Echo: {}\n", msg); + echo_count_++; + if (echo_count_ < 3) { + ws_->send(fmt::format("Hello #{}", echo_count_ + 1)); + } else { + ws_->close(); + } + }); + + ws_->set_on_close([]() { + fmt::print("Done!\n"); + }); + + ws_->set_on_error([](beast::error_code ec) { + if (ec != boost::asio::error::operation_aborted) { + fmt::print(stderr, "Error: {}\n", ec.message()); + } + }); + } + + void start() { + ws_->connect("wss://echo.websocket.org", [this](beast::error_code ec) { + if (!ec) { + ws_->send("Hello #1"); + } + }); + } + +private: + std::shared_ptr ws_; + int echo_count_ = 0; +}; +``` + +### URL 格式 + +| 前缀 | 说明 | +|------|------| +| `ws://host:port/path` | 普通 WebSocket | +| `wss://host:port/path` | TLS 加密 WebSocket | + +### 自定义握手头 + +```cpp +ws->set_header("Authorization", "Bearer token123"); +ws->connect("wss://api.example.com/ws", ...); +``` diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..6885638 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,62 @@ +# khttpd 文档索引 + +## 文档列表 + +| 文档 | 说明 | +|------|------| +| [快速开始指南](quick-start.md) | 10 分钟搭建第一个 khttpd 服务 | +| [API 参考文档](api-reference.md) | 完整 API 方法签名和参数说明 | +| [架构指南](architecture.md) | 框架设计、请求流程、线程模型、扩展点 | +| [高级功能](advanced.md) | 拦截器、异常处理、WebSocket、Cron、DI 容器、Cookie | +| [HTTP 与 WebSocket 客户端](http-client.md) | 内置客户端 API、API_CALL 宏、WebSocket 客户端 | + +## 按主题查找 + +### 入门 +- 环境要求、安装 Bazel → [快速开始](quick-start.md#环境要求) +- 创建第一个项目 → [快速开始](quick-start.md#创建项目) +- 构建与运行 → [快速开始](quick-start.md#构建并运行) + +### 路由 +- HTTP 路由注册 → [API 参考](api-reference.md#httprouter) +- 路径参数语法 → [API 参考](api-reference.md#路由语法) +- 路由优先级 → [API 参考](api-reference.md#路由优先级) +- Controller 模式 → [API 参考](api-reference.md#basecontroller) +- WebSocket 路由 → [高级功能](advanced.md#websocket) + +### 请求/响应 +- 获取查询参数 → [API 参考](api-reference.md#参数提取) +- 获取路径参数 → [API 参考](api-reference.md#参数提取) +- 解析 JSON → [API 参考](api-reference.md#json-解析) +- 解析表单 → [API 参考](api-reference.md#表单与文件上传) +- 文件上传 → [API 参考](api-reference.md#表单与文件上传) +- 设置响应 → [API 参考](api-reference.md#响应设置) +- 分块流式响应 → [高级功能](advanced.md#分块流式响应) +- Cookie 操作 → [高级功能](advanced.md#cookie-操作) + +### 中间件 +- 拦截器 → [高级功能](advanced.md#拦截器interceptors) +- 异常处理 → [高级功能](advanced.md#异常处理) +- 上下文数据传递 → [高级功能](advanced.md#上下文数据传递) + +### 定时任务 +- Cron 调度器 → [高级功能](advanced.md#cron-定时任务) +- Cron 表达式 → [高级功能](advanced.md#cron-表达式6-字段秒-分-时-日-月-周) + +### 依赖注入 +- 注册与解析 → [高级功能](advanced.md#依赖注入di-container) +- 嵌套依赖 → [高级功能](advanced.md#嵌套依赖) +- 循环依赖检测 → [高级功能](advanced.md#循环依赖检测) + +### 客户端 +- HTTP 客户端 → [HTTP 客户端](http-client.md#http-客户端) +- Oat++ 风格 API 定义 → [HTTP 客户端](http-client.md#oat-风格-api-定义) +- 多 Host 权重分发 → [HTTP 客户端](http-client.md#多-host-权重分发) +- API_CALL 宏 → [HTTP 客户端](http-client.md#api_call-宏自动生成客户端方法) +- WebSocket 客户端 → [HTTP 客户端](http-client.md#websocket-客户端) + +### 架构 +- 请求处理流程 → [架构指南](architecture.md#请求处理流程) +- WebSocket 升级流程 → [架构指南](architecture.md#websocket-升级流程) +- 线程模型 → [架构指南](architecture.md#1-线程模型) +- 扩展点 → [架构指南](architecture.md#扩展点) diff --git a/doc/quick-start.md b/doc/quick-start.md new file mode 100644 index 0000000..101ae9f --- /dev/null +++ b/doc/quick-start.md @@ -0,0 +1,161 @@ +# 快速开始指南 + +本文档帮助你在 10 分钟内搭建并运行第一个 khttpd 服务。 + +## 环境要求 + +| 工具 | 最低版本 | +|------|----------| +| [Bazel](https://bazel.build/) / [Bazelisk](https://github.com/bazelbuild/bazelisk) | 7.0+ | +| C++ 编译器 | C++17 支持 (Clang 13+, GCC 9+) | +| 操作系统 | macOS, Linux | + +## 安装 Bazel + +推荐使用 Bazelisk(自动管理 Bazel 版本): + +```bash +# macOS +brew install bazelisk + +# Linux +go install github.com/bazelbuild/bazelisk@latest +``` + +## 创建项目 + +### 1. 初始化项目目录 + +```bash +mkdir my-khttpd-app && cd my-khttpd-app +``` + +### 2. 创建 MODULE.bazel + +```python +module(name = "my-khttpd-app", version = "0.1.0") + +bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "rules_cc", version = "0.2.13") +bazel_dep(name = "fmt", version = "12.0.0") +bazel_dep(name = "boost", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.asio", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.beast", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.json", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.filesystem", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.url", version = "1.89.0.bcr.2") +bazel_dep(name = "boost.uuid", version = "1.89.0.bcr.2") +bazel_dep(name = "boringssl", version = "0.20251110.0") + +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +http_archive( + name = "khttpd", + strip_prefix = "khttpd-0.1.0", + url = "https://github.com/ClangTools/khttpd/archive/refs/tags/v0.1.0.tar.gz", +) +``` + +### 3. 创建 BUILD.bazel + +```python +load("@rules_cc//cc:defs.bzl", "cc_binary") + +cc_binary( + name = "app", + srcs = ["main.cpp"], + deps = ["@khttpd//framework"], +) +``` + +### 4. 编写 main.cpp + +```cpp +#include "framework/server.hpp" +#include "framework/context/http_context.hpp" +#include +#include +#include + +namespace net = boost::asio; +using tcp = boost::asio::ip::tcp; + +int main() +{ + auto const address = net::ip::make_address("0.0.0.0"); + auto const port = static_cast(8080); + auto const threads = std::max(1, std::thread::hardware_concurrency()); + + auto server = std::make_shared( + tcp::endpoint{address, port}, "web_root", threads); + + auto& router = server->get_http_router(); + + // 简单路由 + router.get("/", [](khttpd::framework::HttpContext& ctx) { + ctx.set_status(boost::beast::http::status::ok); + ctx.set_content_type("text/plain"); + ctx.set_body("Hello, khttpd!"); + }); + + // 路径参数 + router.get("/hello/:name", [](khttpd::framework::HttpContext& ctx) { + auto name = ctx.get_path_param("name").value_or("World"); + ctx.set_body(fmt::format("Hello, {}!", name)); + }); + + // JSON API + router.post("/api/echo", [](khttpd::framework::HttpContext& ctx) { + if (auto json = ctx.get_json()) { + ctx.set_body_json(*json); + } else { + ctx.set_status(boost::beast::http::status::bad_request); + ctx.set_body("Invalid JSON"); + } + }); + + server->run(); + return 0; +} +``` + +### 5. 构建并运行 + +```bash +# 构建 +bazel build //:app + +# 运行 +bazel run //:app +``` + +### 6. 测试 + +```bash +curl http://localhost:8080/ +# Hello, khttpd! + +curl http://localhost:8080/hello/World +# Hello, World! + +curl -X POST -H "Content-Type: application/json" \ + -d '{"msg":"hi"}' http://localhost:8080/api/echo +# {"msg":"hi"} +``` + +## 下一步 + +- [API 文档](api-reference.md) — 完整 API 参考 +- [架构指南](architecture.md) — 框架设计与核心概念 +- [高级功能](advanced.md) — 拦截器、异常处理、WebSocket、Cron 调度、DI 容器 +- [HTTP 客户端](http-client.md) — 使用内置 HTTP / WebSocket 客户端 + +## 目录结构 + +``` +my-khttpd-app/ +├── MODULE.bazel # Bazel 模块依赖 +├── BUILD.bazel # 构建目标 +├── main.cpp # 应用入口 +└── web_root/ # 静态文件目录(可选) + └── index.html +``` diff --git a/example/BUILD.bazel b/example/BUILD.bazel index b12ce33..6822005 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -1,3 +1,5 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary") + cc_binary( name = "app", srcs = [ diff --git a/framework/client/api_macros.hpp b/framework/client/api_macros.hpp new file mode 100644 index 0000000..f4dfbda --- /dev/null +++ b/framework/client/api_macros.hpp @@ -0,0 +1,46 @@ +#ifndef KHTTPD_FRAMEWORK_CLIENT_API_MACROS_HPP +#define KHTTPD_FRAMEWORK_CLIENT_API_MACROS_HPP + +// Compiler warning suppression (must come before any includes that might trigger warnings) +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments" +#pragma clang diagnostic ignored "-Wvariadic-macro-arguments-omitted" +#pragma clang diagnostic ignored "-Wpedantic" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" +#endif + +// API Client definition macro (with single host) +#define KHTTPD_API_CLIENT(Name, Host) \ + class Name : public khttpd::framework::client::HttpClient { \ + public: \ + Name() { set_base_url(Host); } \ + explicit Name(boost::asio::io_context& ioc) : HttpClient(ioc) { set_base_url(Host); } + +// API Client definition macro (with multi-host pool) +#define KHTTPD_API_CLIENT_POOL(Name, ...) \ + class Name : public khttpd::framework::client::HttpClient { \ + public: \ + Name() { \ + static const std::vector pool_hosts = { __VA_ARGS__ }; \ + set_base_url_pool(pool_hosts); \ + } \ + explicit Name(boost::asio::io_context& ioc) : HttpClient(ioc) { \ + static const std::vector pool_hosts = { __VA_ARGS__ }; \ + set_base_url_pool(pool_hosts); \ + } + +#define KHTTPD_API_CLIENT_END() }; + +// Host entry shorthand — includes trailing comma for use in initializer lists +#define KHTTPD_HOST(Url, W) {Url, W}, + +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +#endif // KHTTPD_FRAMEWORK_CLIENT_API_MACROS_HPP diff --git a/framework/client/host_pool.cpp b/framework/client/host_pool.cpp new file mode 100644 index 0000000..112f947 --- /dev/null +++ b/framework/client/host_pool.cpp @@ -0,0 +1,49 @@ +#include "host_pool.hpp" +#include +#include + +namespace khttpd::framework::client +{ + HostPool::HostPool(std::vector hosts) + : hosts_(std::move(hosts)) + , total_weight_(0) + , rng_(std::random_device{}()) + { + for (const auto& h : hosts_) + { + urls_.push_back(h.url); + total_weight_ += std::max(1, h.weight); + cumulative_weights_.push_back(total_weight_); + } + } + + const std::string& HostPool::pick() + { + if (cumulative_weights_.empty()) + { + static const std::string empty; + return empty; + } + if (cumulative_weights_.size() == 1) + { + return urls_[0]; + } + + std::uniform_int_distribution dist(1, total_weight_); + int r = dist(rng_); + + auto it = std::lower_bound(cumulative_weights_.begin(), cumulative_weights_.end(), r); + size_t idx = std::distance(cumulative_weights_.begin(), it); + return urls_[idx]; + } + + const std::vector& HostPool::all_urls() const + { + return urls_; + } + + int HostPool::total_weight() const + { + return total_weight_; + } +} diff --git a/framework/client/host_pool.hpp b/framework/client/host_pool.hpp new file mode 100644 index 0000000..938b017 --- /dev/null +++ b/framework/client/host_pool.hpp @@ -0,0 +1,40 @@ +#ifndef KHTTPD_FRAMEWORK_CLIENT_HOST_POOL_HPP +#define KHTTPD_FRAMEWORK_CLIENT_HOST_POOL_HPP + +#include +#include +#include + +namespace khttpd::framework::client +{ + struct HostEntry + { + std::string url; + int weight; + }; + + // Manages multiple base URLs with weighted random selection. + class HostPool + { + public: + explicit HostPool(std::vector hosts); + + // Pick one host URL based on weight (weighted random). + const std::string& pick(); + + // All unique host URLs. + const std::vector& all_urls() const; + + // Total weight sum. + int total_weight() const; + + private: + std::vector hosts_; + std::vector urls_; + std::vector cumulative_weights_; + int total_weight_; + std::mt19937 rng_; + }; +} + +#endif diff --git a/framework/client/http_client.cpp b/framework/client/http_client.cpp index 2206470..6618b03 100644 --- a/framework/client/http_client.cpp +++ b/framework/client/http_client.cpp @@ -228,6 +228,7 @@ namespace khttpd::framework::client void HttpClient::set_base_url(const std::string& url) { + host_pool_.reset(); // Clear pool, revert to single host auto result = boost::urls::parse_uri(url); if (result.has_value()) { @@ -244,6 +245,24 @@ namespace khttpd::framework::client } } + void HttpClient::set_base_url_pool(const std::vector& hosts) + { + if (hosts.empty()) + { + host_pool_.reset(); + return; + } + if (hosts.size() == 1) + { + // Single host: fall back to set_base_url for simplicity + set_base_url(hosts[0].url); + host_pool_.reset(); + return; + } + host_pool_ = std::make_unique(hosts); + base_url_.reset(); // Clear single host URL + } + void HttpClient::set_default_header(const std::string& key, const std::string& value) { default_headers_[key] = value; @@ -264,7 +283,27 @@ namespace khttpd::framework::client { boost::urls::url u; - if (base_url_.has_value()) + // Use host pool if available (multi-host), otherwise use single base_url_ + if (host_pool_) + { + const std::string& host_url = host_pool_->pick(); + auto pool_res = boost::urls::parse_uri(host_url); + if (pool_res.has_value()) + { + u = pool_res.value(); + } + else + { + auto fallback = boost::urls::parse_uri("http://" + host_url); + if (fallback.has_value()) u = fallback.value(); + } + if (!path_in.empty()) + { + if (path_in.front() != '/') u.set_path(u.path() + "/" + path_in); + else u.set_path(path_in); + } + } + else if (base_url_.has_value()) { u = base_url_.value(); if (!path_in.empty()) diff --git a/framework/client/http_client.hpp b/framework/client/http_client.hpp index 88fd585..500b139 100644 --- a/framework/client/http_client.hpp +++ b/framework/client/http_client.hpp @@ -20,6 +20,8 @@ #include #include +#include "client/host_pool.hpp" + namespace khttpd::framework::client { namespace beast = boost::beast; @@ -66,6 +68,19 @@ namespace khttpd::framework::client // Helper: Replace function std::string replace_all(std::string str, const std::string& from, const std::string& to); + // Verb conversion helper + inline http::verb verb_from_string(const std::string& s) + { + if (s == "GET" || s == "get") return http::verb::get; + if (s == "POST" || s == "post") return http::verb::post; + if (s == "PUT" || s == "put") return http::verb::put; + if (s == "DELETE" || s == "delete") return http::verb::delete_; + if (s == "PATCH" || s == "patch") return http::verb::patch; + if (s == "HEAD" || s == "head") return http::verb::head; + if (s == "OPTIONS" || s == "options") return http::verb::options; + return http::verb::get; + } + class HttpClient : public std::enable_shared_from_this { public: @@ -85,6 +100,7 @@ namespace khttpd::framework::client // Configuration void set_base_url(const std::string& url); + void set_base_url_pool(const std::vector& hosts); void set_default_header(const std::string& key, const std::string& value); void set_bearer_token(const std::string& token); void set_timeout(std::chrono::seconds seconds); @@ -123,12 +139,13 @@ namespace khttpd::framework::client ssl::context* ssl_ctx_ptr_; // Points to the active context std::optional base_url_; + std::unique_ptr host_pool_; // Multi-host support (null if single host) std::map default_headers_; std::chrono::seconds timeout_{30}; }; } -// Include macros at the end +// Include legacy macros (backward compatibility) #include "macros.hpp" #endif // KHTTPD_FRAMEWORK_CLIENT_HTTP_CLIENT_HPP diff --git a/framework/context/http_context.cpp b/framework/context/http_context.cpp index 5fee0f2..4905392 100644 --- a/framework/context/http_context.cpp +++ b/framework/context/http_context.cpp @@ -15,7 +15,7 @@ namespace khttpd::framework const size_t first = str.find_first_not_of(" \t\n\r"); if (std::string::npos == first) { - return str; + return ""; } const size_t last = str.find_last_not_of(" \t\n\r"); return str.substr(first, last - first + 1); @@ -427,6 +427,21 @@ namespace khttpd::framework void HttpContext::set_cookie(const std::string& key, const std::string& value, const CookieOptions& options) const { + // Reject values containing characters that could break the header or enable injection + if (key.find(';') != std::string::npos || key.find(',') != std::string::npos || + key.find('\r') != std::string::npos || key.find('\n') != std::string::npos || + key.find('=') != std::string::npos) + { + fmt::print(stderr, "Warning: Invalid cookie key '{}' - contains prohibited characters\n", key); + return; + } + if (value.find(';') != std::string::npos || value.find(',') != std::string::npos || + value.find('\r') != std::string::npos || value.find('\n') != std::string::npos) + { + fmt::print(stderr, "Warning: Invalid cookie value '{}' - contains prohibited characters\n", value); + return; + } + std::string cookie_str = key + "=" + value; if (options.max_age >= 0) diff --git a/framework/context/websocket_context.cpp b/framework/context/websocket_context.cpp index fa5db7c..3765788 100644 --- a/framework/context/websocket_context.cpp +++ b/framework/context/websocket_context.cpp @@ -9,8 +9,7 @@ namespace khttpd::framework { WebsocketContext::WebsocketContext(std::weak_ptr session, std::string msg, bool text, std::string path_str) - : session_weak_ptr(std::move(session)), message(std::move(msg)), is_text(text), path(std::move(path_str)), - session_(std::move(session)) + : session_weak_ptr(std::move(session)), message(std::move(msg)), is_text(text), path(std::move(path_str)) { if (const auto session_shared_ptr = session_weak_ptr.lock()) { diff --git a/framework/context/websocket_context.hpp b/framework/context/websocket_context.hpp index 2d82147..e560b0b 100644 --- a/framework/context/websocket_context.hpp +++ b/framework/context/websocket_context.hpp @@ -20,8 +20,7 @@ namespace khttpd::framework bool is_text; boost::beast::error_code error_code; std::string path; - std::weak_ptr session_; - + std::map extended_data; WebsocketContext(std::weak_ptr session, std::string msg, bool text, diff --git a/framework/di/di_container.hpp b/framework/di/di_container.hpp index cd23997..d771f5f 100644 --- a/framework/di/di_container.hpp +++ b/framework/di/di_container.hpp @@ -8,41 +8,37 @@ #include // For std::shared_ptr #include // For std::type_index #include // For std::map +#include // For std::set #include // For std::function #include // For std::runtime_error #include // For typeid(T).name() +#include // For std::mutex namespace khttpd { namespace framework { - // 前向声明 DI_Container - class DI_Container; - - // 基础组件类 class ComponentBase { public: - virtual ~ComponentBase() = default; // 确保多态析构 + virtual ~ComponentBase() = default; }; - // DI容器类 class DI_Container { public: - // Meyers' Singleton 模式,保证DI_Container是全局唯一的实例 static DI_Container& instance() { - static DI_Container container; // 线程安全(C++11保证静态局部变量初始化是线程安全的) + static DI_Container container; return container; } - // 注册一个组件及其构造函数依赖 template void register_component() { const auto type_idx = std::type_index(typeid(T)); + std::unique_lock lock(mtx_); if (component_factories_.count(type_idx)) { std::cerr << "Warning: Component " << typeid(T).name() << " already registered. Overwriting." << std::endl; @@ -54,44 +50,64 @@ namespace khttpd }; component_factories_[type_idx] = factory; - // std::cout << "Registered component: " << typeid(T).name() << std::endl; // For testing, suppress verbose output } - // 解析并获取一个组件的实例 template std::shared_ptr resolve() const { const auto type_idx = std::type_index(typeid(T)); - // 1. 尝试从单例缓存中获取 + std::unique_lock lock(mtx_); + + // Check singleton cache if (const auto it_singleton = singletons_.find(type_idx); it_singleton != singletons_.end()) { - // std::cout << "Returning cached instance for: " << typeid(T).name() << std::endl; // For testing, suppress verbose output return std::static_pointer_cast(it_singleton->second); } - // 2. 如果不在缓存中,查找工厂函数 + // Circular dependency detection + if (resolving_.count(type_idx)) + { + throw std::runtime_error("Circular dependency detected while resolving: " + std::string(typeid(T).name())); + } + + // Find factory const auto it_factory = component_factories_.find(type_idx); if (it_factory == component_factories_.end()) { throw std::runtime_error("Component not registered or dependency missing: " + std::string(typeid(T).name())); } - // 3. 调用工厂函数创建新实例 - std::shared_ptr instance = std::static_pointer_cast(it_factory->second(*this)); + // Mark as resolving + resolving_.insert(type_idx); - // 4. 将新创建的实例缓存为单例(所有注册的组件默认都是单例) - singletons_[type_idx] = instance; + try + { + // Temporarily release lock during factory execution to avoid deadlocks + // if factories call resolve() recursively (for different types) + lock.unlock(); + + std::shared_ptr instance = std::static_pointer_cast(it_factory->second(*this)); - // std::cout << "Resolved and cached instance for: " << typeid(T).name() << std::endl; // For testing, suppress verbose output - return instance; + lock.lock(); + resolving_.erase(type_idx); + singletons_[type_idx] = instance; + return instance; + } + catch (...) + { + // Clean up resolving set on exception + resolving_.erase(type_idx); + throw; + } } - // **新增:清除容器状态的方法,用于测试** void clear() { + std::unique_lock lock(mtx_); component_factories_.clear(); singletons_.clear(); + resolving_.clear(); } DI_Container(const DI_Container&) = delete; @@ -100,9 +116,10 @@ namespace khttpd private: DI_Container() = default; - protected: // Changed from private for testing purposes (specifically for clear()) + mutable std::mutex mtx_; std::map(const DI_Container&)>> component_factories_; mutable std::map> singletons_; + mutable std::set resolving_; }; } diff --git a/framework/io_context_pool.hpp b/framework/io_context_pool.hpp index 3533e47..6b0252e 100644 --- a/framework/io_context_pool.hpp +++ b/framework/io_context_pool.hpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include namespace khttpd::framework { @@ -34,57 +36,61 @@ namespace khttpd::framework ~IoContextPool() { - stop(); + shutdown(); } void stop() { - // 确保只停止一次,防止析构和显式调用 stop 冲突 - std::call_once(stop_flag_, [this]() + // 快速路径:重置 work guard 并通知 io_context 停止 + // 不在此函数中 join 线程,避免信号处理线程中 join 自身导致崩溃 + if (!stopped_.exchange(true)) { - work_guard_.reset(); // 允许 run() 退出 - ioc_.stop(); // 显式发出停止信号 + work_guard_.reset(); + ioc_.stop(); + } + } + + // 等待所有工作线程结束(由析构函数调用,不在信号上下文中) + void shutdown() + { + if (!stopped_.exchange(true)) + { + work_guard_.reset(); + ioc_.stop(); + } - // 等待所有线程结束 - for (auto& t : threads_) + // 等待线程结束 — 只在析构路径调用,不在信号处理中 + for (auto& t : threads_) + { + if (t.joinable()) { - if (t.joinable()) - { - t.join(); - } + t.join(); } - threads_.clear(); - }); + } + threads_.clear(); } private: explicit IoContextPool(unsigned int count = std::thread::hardware_concurrency()) : work_guard_(boost::asio::make_work_guard(ioc_)) { - // 如果检测失败(返回0)或者核心数少于1,保底使用 1 个线程 - // 如果为了提高并发吞吐量,也可以设为 count * 2 if (count <= 0) count = 1; threads_.reserve(count * 2); - // 2. 启动线程池 for (unsigned int i = 0; i < count; ++i) { threads_.emplace_back([this]() { - // 每个线程都运行同一个 io_context - // ASIO 会自动调度 handler 到空闲线程 ioc_.run(); }); } } - boost::asio::io_context ioc_; boost::asio::executor_work_guard work_guard_; std::vector threads_; - std::once_flag stop_flag_; + std::atomic stopped_{false}; }; } - #endif // KHTTPD_FRAMEWORK_CLIENT_IO_CONTEXT_POOL_HPP diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index bdd8cb1..3309e8d 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -17,11 +17,11 @@ namespace khttpd::framework int literal_segments = 0; int dynamic_segments = 0; - std::sregex_iterator it(path_pattern.begin(), path_pattern.end(), param_regex); - std::sregex_iterator end; + std::regex escape_regex(R"([\\\.\+\*\?\|\(\)\[\]\{\}\^\$])"); auto current_pos = path_pattern.begin(); int param_count = 0; + std::sregex_iterator end; for (std::sregex_iterator temp_it(path_pattern.begin(), path_pattern.end(), param_regex); temp_it != end; ++temp_it) { @@ -30,7 +30,7 @@ namespace khttpd::framework int current_param_index = 0; - it = std::sregex_iterator(path_pattern.begin(), path_pattern.end(), param_regex); + auto it = std::sregex_iterator(path_pattern.begin(), path_pattern.end(), param_regex); while (it != end) { @@ -53,7 +53,7 @@ namespace khttpd::framework literal_segments++; } } - regex_str += std::regex_replace(literal_part, std::regex(R"([\.\+\*\?\|\(\)\[\]\{\}\^\$])"), "\\$&"); + regex_str += std::regex_replace(literal_part, escape_regex, "\\$&"); param_names.push_back(it->str().substr(1)); dynamic_segments++; @@ -90,7 +90,7 @@ namespace khttpd::framework literal_segments++; } } - regex_str += std::regex_replace(tail_literal_part, std::regex(R"([\.\+\*\?\|\(\)\[\]\{\}\^\$])"), "\\$&"); + regex_str += std::regex_replace(tail_literal_part, escape_regex, "\\$&"); regex_str += "$"; return {std::regex(regex_str), param_names, literal_segments, dynamic_segments}; diff --git a/framework/server.cpp b/framework/server.cpp index f457b9d..290c7a2 100644 --- a/framework/server.cpp +++ b/framework/server.cpp @@ -44,7 +44,14 @@ namespace khttpd::framework throw std::runtime_error(fmt::format("Failed to listen: {}", ec.message())); } - // 检查 web_root 路径 + // Pre-compute canonical web root path once (not per-connection) + boost::system::error_code path_ec; + canonical_web_root_ = boost::filesystem::canonical(web_root_, path_ec); + if (path_ec) + { + fmt::print(stderr, "Warning: Cannot canonicalize web_root '{}': {}\n", web_root_, path_ec.message()); + } + if (!boost::filesystem::exists(web_root_, ec)) { fmt::print(stderr, "Warning: Web root directory '{}' does not exist. Static file serving may fail. Error: {}\n", @@ -127,7 +134,7 @@ namespace khttpd::framework } else { - std::make_shared(std::move(socket), http_router_, websocket_router_, web_root_)->run(); + std::make_shared(std::move(socket), http_router_, websocket_router_, web_root_, canonical_web_root_)->run(); } if (acceptor_.is_open()) diff --git a/framework/server.hpp b/framework/server.hpp index d61f7f0..97283e9 100644 --- a/framework/server.hpp +++ b/framework/server.hpp @@ -5,12 +5,10 @@ #include #include #include -#include -#include +#include #include #include -// 包含完整定义,因为 Server 现在拥有它们 #include "router/http_router.hpp" #include "router/websocket_router.hpp" @@ -22,27 +20,24 @@ namespace khttpd::framework class Server : public std::enable_shared_from_this { public: - // 构造函数:现在只接受端口和线程数量。路由器在内部创建。 Server(const tcp::endpoint& endpoint, std::string web_root, int num_threads = 1); HttpRouter& get_http_router(); - const HttpRouter& get_http_router() const; // const 版本 + const HttpRouter& get_http_router() const; void add_interceptor(std::shared_ptr interceptor); WebsocketRouter& get_websocket_router(); - const WebsocketRouter& get_websocket_router() const; // const 版本 + const WebsocketRouter& get_websocket_router() const; void run(); void stop(); private: - // std::optional ioc_; - // int num_threads_; - std::vector threads_; net::signal_set signals_; const std::string web_root_; + boost::filesystem::path canonical_web_root_; tcp::acceptor acceptor_; diff --git a/framework/session/http_session.cpp b/framework/session/http_session.cpp index 27f7a75..0521d17 100644 --- a/framework/session/http_session.cpp +++ b/framework/session/http_session.cpp @@ -1,28 +1,25 @@ #include "http_session.hpp" -#include - #include "context/http_context.hpp" #include +#include +#include #include using namespace khttpd::framework; HttpSession::HttpSession(tcp::socket&& socket, HttpRouter& router, WebsocketRouter& ws_router, - const std::string& web_root) + const std::string& web_root, + const boost::filesystem::path& canonical_web_root) : stream_(std::move(socket)), router_(router), websocket_router_(ws_router), - web_root_path_(web_root) + web_root_path_(web_root), + canonical_web_root_path_(canonical_web_root) { - boost::system::error_code ec; - // 在构造函数中规范化 web_root 路径,避免重复操作 - canonical_web_root_path_ = boost::filesystem::canonical(web_root_path_, ec); - if (ec) + if (canonical_web_root_path_.empty()) { - fmt::print(stderr, "Error canonicalizing web_root_path_ '{}': {}\n", web_root_path_.string(), ec.message()); - // 如果 web_root 本身就无效,后续静态文件服务都会失败 disable_web_root_ = true; } } @@ -75,8 +72,6 @@ void HttpSession::handle_request() // 1. Run Pre-interceptors if (router_.run_pre_interceptors(*ctx) == InterceptorResult::Stop) { - // Interceptor decided to stop (rejected or responded directly) - // Run post-interceptors on the response generated by the interceptor router_.run_post_interceptors(*ctx); if (res_.chunked()) @@ -94,7 +89,6 @@ void HttpSession::handle_request() // 2. Dispatch to routes or static files router_.dispatch(*ctx, [this, &static_file_served] { - // 处理 GET 请求以尝试服务静态文件 if (req_.method() == http::verb::get || req_.method() == http::verb::head) { static_file_served = do_serve_static_file(); @@ -102,14 +96,12 @@ void HttpSession::handle_request() return static_file_served; }); - // If static file was served, the response is already sent. - // We skip post-interceptors and explicit send_response. if (static_file_served) { return; } - // 3. Run Post-interceptors (always run if we reached here, i.e., dynamic route or 404) + // 3. Run Post-interceptors router_.run_post_interceptors(*ctx); if (res_.chunked()) @@ -124,28 +116,31 @@ void HttpSession::handle_request() catch (...) { router_.handle_exception(std::current_exception(), *ctx); - // Ensure response is sent if not already (we assume exception happened before sending) - // We might want to clear previous body if it was partially written in buffer? - // res_ is wrapped in ctx, and handle_exception modifies ctx/res_. send_response(std::move(res_)); } } -// 尝试服务静态文件 +// Extract path from request target (query-stripped) +static std::string extract_path_from_target(std::string_view target) +{ + auto qpos = target.find('?'); + if (qpos != std::string_view::npos) + { + return std::string(target.substr(0, qpos)); + } + return std::string(target); +} + bool HttpSession::do_serve_static_file() { - // 确保 web_root_path_ 是有效的 if (canonical_web_root_path_.empty()) { - // web_root 本身就无效,不尝试服务静态文件 return false; } - // 使用 HttpContext 获取请求路径,因为它已经去除了查询字符串 - HttpContext temp_ctx(req_, res_); // 临时创建 ctx 来获取 path() - boost::filesystem::path request_path = temp_ctx.path(); + std::string request_path_str = extract_path_from_target(req_.target()); + boost::filesystem::path request_path(request_path_str); - // 如果请求的是根路径,尝试提供 index.html if (request_path == "/") { request_path = "/index.html"; @@ -154,88 +149,81 @@ bool HttpSession::do_serve_static_file() boost::filesystem::path full_local_path = web_root_path_ / request_path.relative_path(); boost::system::error_code ec; - // 1. 规范化路径以防止目录遍历攻击 (e.g., /../) + // 1. Normalize path to prevent directory traversal full_local_path = boost::filesystem::canonical(full_local_path, ec); - // 如果规范化失败 (文件不存在、权限问题、路径无效等) if (ec) { if (ec == boost::system::errc::no_such_file_or_directory) { - // 文件不存在,交由动态路由或 404 处理 return false; } - // 其他错误,例如权限不足或无效路径,直接返回 403 + fmt::print(stderr, "Error canonicalizing path '{}': {}\n", full_local_path.string(), ec.message()); http::response forbidden_res{http::status::forbidden, req_.version()}; forbidden_res.keep_alive(req_.keep_alive()); forbidden_res.set(http::field::server, BOOST_BEAST_VERSION_STRING); forbidden_res.set(http::field::content_type, "text/html"); - forbidden_res.body() = fmt::format("

403 Forbidden

Access denied due to invalid path: {}. Error: {}

", - request_path.string(), ec.message()); + forbidden_res.body() = fmt::format("

403 Forbidden

Access denied due to invalid path.

"); forbidden_res.prepare_payload(); send_response(std::move(forbidden_res)); - return true; // 已处理请求 + return true; } - // 2. 安全检查:确保规范化后的路径仍在 Web 根目录内 + // 2. Security: ensure path is within web root const std::string& full_path_str = full_local_path.string(); const std::string& root_path_str = canonical_web_root_path_.string(); - if (full_path_str.substr(0, root_path_str.size()) != root_path_str) + if (full_path_str.size() < root_path_str.size() || + full_path_str.substr(0, root_path_str.size()) != root_path_str) { http::response forbidden_res{http::status::forbidden, req_.version()}; forbidden_res.keep_alive(req_.keep_alive()); forbidden_res.set(http::field::server, BOOST_BEAST_VERSION_STRING); forbidden_res.set(http::field::content_type, "text/html"); - forbidden_res.body() = fmt::format( - "

403 Forbidden

Access denied: Path traversal attempt detected for {}.

", request_path.string()); + forbidden_res.body() = fmt::format("

403 Forbidden

Access denied: Path traversal attempt detected.

"); forbidden_res.prepare_payload(); send_response(std::move(forbidden_res)); - return true; // 已处理请求 + return true; } - // 3. 检查是否为目录 + // 3. Check if directory if (boost::filesystem::is_directory(full_local_path, ec)) { if (ec) { - /* 错误处理 */ + fmt::print(stderr, "Error checking if path is directory '{}': {}\n", full_local_path.string(), ec.message()); + return false; } - // 如果是目录,尝试提供 index.html boost::filesystem::path index_file_path = full_local_path / "index.html"; if (boost::filesystem::is_regular_file(index_file_path, ec)) { - full_local_path = index_file_path; // 将路径指向 index.html + full_local_path = index_file_path; } else { - // 目录不包含 index.html,且不允许目录列表 http::response forbidden_res{http::status::forbidden, req_.version()}; forbidden_res.keep_alive(req_.keep_alive()); forbidden_res.set(http::field::server, BOOST_BEAST_VERSION_STRING); forbidden_res.set(http::field::content_type, "text/html"); - forbidden_res.body() = fmt::format("

403 Forbidden

Directory listing not allowed for {}.

", - request_path.string()); + forbidden_res.body() = fmt::format("

403 Forbidden

Directory listing not allowed.

"); forbidden_res.prepare_payload(); send_response(std::move(forbidden_res)); - return true; // 已处理请求 + return true; } } - // 4. 最终检查:确保是常规文件 + // 4. Final check: regular file if (!boost::filesystem::is_regular_file(full_local_path, ec) || ec) { - // 如果不是常规文件,或者存在其他错误,交由动态路由或 404 处理 return false; } - // 5. 文件存在且是常规文件,现在开始发送文件 + // 5. Serve file http::response file_res; file_res.version(req_.version()); file_res.keep_alive(req_.keep_alive()); - file_res.result(http::status::ok); // 默认 200 OK + file_res.result(http::status::ok); file_res.set(http::field::server, BOOST_BEAST_VERSION_STRING); - // 打开文件 file_res.body().open(full_local_path.string().c_str(), beast::file_mode::scan, ec); if (ec) { @@ -247,20 +235,17 @@ bool HttpSession::do_serve_static_file() internal_error_res.body() = "

500 Internal Server Error

Could not open the requested file.

"; internal_error_res.prepare_payload(); send_response(std::move(internal_error_res)); - return true; // 已处理请求 + return true; } - // 设置 Content-Type std::string extension = full_local_path.extension().string(); - std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower); // 转换为小写 + std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower); file_res.set(http::field::content_type, mime_type_from_extension(extension)); - // 准备 payload (这会自动设置 Content-Length) file_res.prepare_payload(); - // 发送响应 send_response(std::move(file_res)); - return true; // 静态文件已处理 + return true; } void HttpSession::send_chunked_response() @@ -268,6 +253,12 @@ void HttpSession::send_chunked_response() res_.body() = ""; sr_.emplace(res_); + // Initialize chunked writing state + chunk_queue_ = std::make_shared>(); + chunk_mtx_ = std::make_shared(); + chunk_writing_ = std::make_shared(false); + chunk_error_ = std::make_shared(); + http::async_write_header(stream_, *sr_, beast::bind_front_handler( &HttpSession::on_write_header, @@ -289,33 +280,59 @@ void HttpSession::on_write_header(beast::error_code ec, std::size_t bytes_transf fmt::print(stderr, "HttpSession on_write_header error: {}\n", ec.message()); return; } - std::thread([self = shared_from_this()]() + + // Async chunk writer: posts each chunk write to the io_context executor. + // The WriteHandler synchronously waits for the async write to complete + // so the user's HttpStreamHandler can use a simple synchronous loop. + auto write_chunk = [this](const std::string& buffer) -> bool { - std::mutex mtx; - self->ctx->get_stream_handler()(*self->ctx, [self, &mtx](const std::string& buffer) + struct WriteState { - try - { - std::unique_lock lock{mtx}; - // std::stringstream ss_header; - // ss_header << std::hex << buffer.length() << "\r\n" << buffer << "\r\n"; - // net::write(self->stream_, net::buffer(ss_header.str())); - - std::stringstream ss_header; - ss_header << std::hex << buffer.length() << "\r\n"; - net::write(self->stream_, net::buffer(ss_header.str())); - net::write(self->stream_, net::buffer(buffer.data(), buffer.length())); - net::write(self->stream_, net::buffer("\r\n", 2)); - return true; - } - catch (std::exception& e) - { - fmt::print(stderr, "Exception: {}\n", e.what()); - return false; - } - }); - self->do_write_final_chunk(); - }).detach(); + std::mutex mtx; + std::condition_variable cv; + beast::error_code ec; + bool done = false; + }; + auto state = std::make_shared(); + + // Build chunk: hex-length \r\n body \r\n + std::stringstream ss; + ss << std::hex << buffer.length() << "\r\n" << buffer << "\r\n"; + auto data = std::make_shared(ss.str()); + + // Post async write to executor + net::post(stream_.get_executor(), + [self = shared_from_this(), data, state]() + { + net::async_write(self->stream_, net::buffer(*data), + [state](beast::error_code ec, std::size_t) + { + std::unique_lock lock{state->mtx}; + state->ec = ec; + state->done = true; + state->cv.notify_one(); + }); + }); + + // Wait for async write to complete + std::unique_lock lock{state->mtx}; + state->cv.wait(lock, [&state] { return state->done; }); + + if (state->ec) + { + fmt::print(stderr, "Chunked write error: {}\n", state->ec.message()); + return false; + } + return true; + }; + + // Invoke the user's stream handler with our async-backed WriteHandler + if (ctx->get_stream_handler()) + { + ctx->get_stream_handler()(*ctx, write_chunk); + } + + do_write_final_chunk(); } void HttpSession::do_write_final_chunk() @@ -381,6 +398,5 @@ std::string HttpSession::mime_type_from_extension(const std::string& ext) if (ext == ".svg") return "image/svg+xml"; if (ext == ".pdf") return "application/pdf"; if (ext == ".txt") return "text/plain"; - // Add more MIME types as needed - return "application/octet-stream"; // Default for unknown types + return "application/octet-stream"; } diff --git a/framework/session/http_session.hpp b/framework/session/http_session.hpp index 0e946b7..0f1a368 100644 --- a/framework/session/http_session.hpp +++ b/framework/session/http_session.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include "router/http_router.hpp" #include "websocket/websocket_session.hpp" @@ -23,7 +25,9 @@ namespace khttpd::framework class HttpSession : public std::enable_shared_from_this { public: - HttpSession(tcp::socket&& socket, HttpRouter& router, WebsocketRouter& ws_router, const std::string& web_root); + HttpSession(tcp::socket&& socket, HttpRouter& router, WebsocketRouter& ws_router, + const std::string& web_root, + const boost::filesystem::path& canonical_web_root); // 启动会话 void run(); @@ -37,11 +41,17 @@ namespace khttpd::framework HttpRouter& router_; WebsocketRouter& websocket_router_; const boost::filesystem::path web_root_path_; - boost::filesystem::path canonical_web_root_path_; + const boost::filesystem::path canonical_web_root_path_; std::shared_ptr ws_session_; std::optional> sr_; std::shared_ptr ctx = nullptr; + // Chunked streaming support + std::shared_ptr> chunk_queue_; + std::shared_ptr chunk_mtx_; + std::shared_ptr chunk_writing_; + std::shared_ptr chunk_error_; + void do_read(); void on_read(const beast::error_code& ec, std::size_t bytes_transferred); diff --git a/framework/tests/BUILD.bazel b/framework/tests/BUILD.bazel index 5b84d0d..db4f82b 100644 --- a/framework/tests/BUILD.bazel +++ b/framework/tests/BUILD.bazel @@ -1,3 +1,5 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") + cc_test( name = "context_test", srcs = ["context_test.cpp"], @@ -87,3 +89,33 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "websocket_context_test", + srcs = ["websocket_context_test.cpp"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "http_context_multipart_edge_test", + srcs = ["http_context_multipart_edge_test.cpp"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) diff --git a/framework/tests/client_test.cpp b/framework/tests/client_test.cpp index 3ca0b97..30ff186 100644 --- a/framework/tests/client_test.cpp +++ b/framework/tests/client_test.cpp @@ -1,5 +1,7 @@ #include "framework/client/http_client.hpp" #include "framework/client/websocket_client.hpp" +#include "framework/client/api_macros.hpp" +#include "framework/client/host_pool.hpp" #include #include #include @@ -415,3 +417,122 @@ TEST_F(ClientTest, ThreadPoolVerify) WAIT_FOR_ASYNC(f1); WAIT_FOR_ASYNC(f2); } + +// ========================================== +// 3. Oat++-style API Client Tests +// ========================================== + +// Define API client using KHTTPD_API_CLIENT (single host, endpoints use API_CALL) +KHTTPD_API_CLIENT(EchoClient, "https://postman-echo.com") + API_CALL(http::verb::get, "/get", get_echo, + QUERY(std::string, msg, "msg")) + API_CALL(http::verb::post, "/post", post_echo, + BODY(boost::json::object, body)) +KHTTPD_API_CLIENT_END() + +// Define API client using KHTTPD_API_CLIENT_POOL (multi-host with weights) +KHTTPD_API_CLIENT_POOL(MultiHostClient, + KHTTPD_HOST("https://postman-echo.com", 3) + KHTTPD_HOST("https://postman-echo.com", 1) +) + API_CALL(http::verb::get, "/get", get_echo, + QUERY(std::string, msg, "msg")) +KHTTPD_API_CLIENT_END() + +// Test verb_from_string +TEST(ApiMacrosTest, VerbFromString) +{ + ASSERT_EQ(verb_from_string("GET"), http::verb::get); + ASSERT_EQ(verb_from_string("get"), http::verb::get); + ASSERT_EQ(verb_from_string("POST"), http::verb::post); + ASSERT_EQ(verb_from_string("post"), http::verb::post); + ASSERT_EQ(verb_from_string("PUT"), http::verb::put); + ASSERT_EQ(verb_from_string("DELETE"), http::verb::delete_); + ASSERT_EQ(verb_from_string("PATCH"), http::verb::patch); + ASSERT_EQ(verb_from_string("HEAD"), http::verb::head); + ASSERT_EQ(verb_from_string("OPTIONS"), http::verb::options); + ASSERT_EQ(verb_from_string("UNKNOWN"), http::verb::get); // fallback +} + +// Test single-host KHTTPD_API_CLIENT +TEST_F(ClientTest, OatppStyleSingleHost) +{ + auto echo = std::make_shared(); + echo->set_timeout(std::chrono::seconds(10)); + + std::promise done; + auto future = done.get_future(); + + echo->get_echo("hello", [&](auto ec, auto res) { + if (!ec) { + EXPECT_EQ(res.result(), http::status::ok); + EXPECT_TRUE(res.body().find("hello") != std::string::npos); + } else { + ADD_FAILURE() << "Network error: " << ec.message(); + } + done.set_value(); + }); + + WAIT_FOR_ASYNC(future); +} + +// Test sync version +TEST_F(ClientTest, OatppStyleSync) +{ + auto echo = std::make_shared(); + echo->set_timeout(std::chrono::seconds(10)); + + try { + auto res = echo->get_echo_sync("sync_test"); + EXPECT_EQ(res.result(), http::status::ok); + EXPECT_TRUE(res.body().find("sync_test") != std::string::npos); + } catch (const std::exception& e) { + ADD_FAILURE() << "Exception: " << e.what(); + } +} + +// Test multi-host pool +TEST(ApiMacrosTest, HostPoolWeighted) +{ + std::vector hosts = { + {"http://host-a.com", 3}, + {"http://host-b.com", 1}, + }; + HostPool pool(hosts); + + // All URLs should be present + auto urls = pool.all_urls(); + ASSERT_EQ(urls.size(), 2); + ASSERT_EQ(urls[0], "http://host-a.com"); + ASSERT_EQ(urls[1], "http://host-b.com"); + + // Total weight should be 4 + ASSERT_EQ(pool.total_weight(), 4); + + // pick() should always return one of the hosts + for (int i = 0; i < 100; ++i) { + const auto& picked = pool.pick(); + ASSERT_TRUE(picked == "http://host-a.com" || picked == "http://host-b.com"); + } +} + +// Test multi-host API client +TEST_F(ClientTest, MultiHostClientPool) +{ + auto mc = std::make_shared(); + mc->set_timeout(std::chrono::seconds(10)); + + std::promise done; + auto future = done.get_future(); + + mc->get_echo("pool_test", [&](auto ec, auto res) { + if (!ec) { + EXPECT_EQ(res.result(), http::status::ok); + } else { + ADD_FAILURE() << "Network error: " << ec.message(); + } + done.set_value(); + }); + + WAIT_FOR_ASYNC(future); +} diff --git a/framework/tests/context_test.cpp b/framework/tests/context_test.cpp index 5ef94a9..107aedd 100644 --- a/framework/tests/context_test.cpp +++ b/framework/tests/context_test.cpp @@ -238,3 +238,154 @@ TEST(HttpContextTest, SetCookie) ASSERT_TRUE(found_foo); ASSERT_TRUE(found_user); } + +// Test set_body_json and set_body_from +TEST(HttpContextTest, SetBodyJson) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + boost::json::object obj; + obj["status"] = "ok"; + obj["count"] = 42; + + ctx.set_body_json(obj); + + auto& actual_res = ctx.get_response(); + ASSERT_EQ(actual_res[http::field::content_type], "application/json"); + ASSERT_EQ(actual_res.body(), R"({"status":"ok","count":42})"); +} + +TEST(HttpContextTest, SetBodyFrom) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + boost::json::object obj; + obj["name"] = "Bob"; + obj["age"] = 25; + + ctx.set_body_from(boost::json::value_from(obj)); + + auto& actual_res = ctx.get_response(); + ASSERT_EQ(actual_res[http::field::content_type], "application/json"); + ASSERT_TRUE(actual_res.body().find("Bob") != std::string::npos); + ASSERT_TRUE(actual_res.body().find("25") != std::string::npos); +} + +// Test path params set/get directly +TEST(HttpContextTest, PathParamsWithoutDispatch) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + std::map params; + params["id"] = "123"; + params["name"] = "alice"; + ctx.set_path_params(params); + + ASSERT_EQ(ctx.get_path_param("id").value(), "123"); + ASSERT_EQ(ctx.get_path_param("name").value(), "alice"); + ASSERT_FALSE(ctx.get_path_param("missing").has_value()); +} + +// Test context attributes +TEST(HttpContextTest, Attributes) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + ctx.set_attribute("user_id", std::string("user-456")); + ctx.set_attribute("role", 3); + + auto uid = ctx.get_attribute_as("user_id"); + ASSERT_TRUE(uid.has_value()); + ASSERT_EQ(uid.value(), "user-456"); + + auto role = ctx.get_attribute_as("role"); + ASSERT_TRUE(role.has_value()); + ASSERT_EQ(role.value(), 3); + + // Missing key + auto missing = ctx.get_attribute_as("missing"); + ASSERT_FALSE(missing.has_value()); + + // Type mismatch + auto wrong_type = ctx.get_attribute_as("role"); + ASSERT_FALSE(wrong_type.has_value()); +} + +// Test header case insensitivity +TEST(HttpContextTest, HeaderCaseInsensitive) +{ + http::request req = make_request(http::verb::get, "/"); + req.set("X-Custom-Header", "value1"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + // Boost.Beast stores headers case-insensitively + ASSERT_TRUE(ctx.get_header("x-custom-header").has_value()); + ASSERT_TRUE(ctx.get_header("X-CUSTOM-HEADER").has_value()); +} + +// Test set_cookie rejects invalid key characters +TEST(HttpContextTest, InvalidCookieKeyChars) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + ctx.set_cookie("bad;key", "value"); + ctx.set_cookie("bad=key", "value"); + + auto& actual_res = ctx.get_response(); + auto range = actual_res.equal_range(http::field::set_cookie); + int count = 0; + for(auto it = range.first; it != range.second; ++it) { count++; } + ASSERT_EQ(count, 0); // No cookies should be set +} + +// Test set_cookie rejects invalid value characters +TEST(HttpContextTest, InvalidCookieValueChars) +{ + http::request req = make_request(http::verb::get, "/"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + ctx.set_cookie("good_key", "bad;value"); + ctx.set_cookie("good_key2", "bad,value"); + + auto& actual_res = ctx.get_response(); + auto range = actual_res.equal_range(http::field::set_cookie); + int count = 0; + for(auto it = range.first; it != range.second; ++it) { count++; } + ASSERT_EQ(count, 0); // No cookies should be set +} + +// Test empty path +TEST(HttpContextTest, EmptyPath) +{ + http::request req = make_request(http::verb::get, ""); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + ASSERT_EQ(ctx.path(), ""); +} + +// Test JSON array body +TEST(HttpContextTest, JsonArrayBody) +{ + std::string json_str = R"([1, "two", {"key": "value"}])"; + http::request req = make_request(http::verb::post, "/api/array", 11, json_str); + req.set(http::field::content_type, "application/json"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + ASSERT_TRUE(ctx.get_json().has_value()); + ASSERT_TRUE(ctx.get_json().value().is_array()); + ASSERT_EQ(ctx.get_json().value().as_array().size(), 3); +} diff --git a/framework/tests/di_container_test.cpp b/framework/tests/di_container_test.cpp index 20a494f..585a588 100644 --- a/framework/tests/di_container_test.cpp +++ b/framework/tests/di_container_test.cpp @@ -267,3 +267,40 @@ TEST_F(DIContainerTest, NestedResolutionOnlyConstructsOnce) ASSERT_EQ(s_DependencyA_count, 1); ASSERT_EQ(mainComp1->getDepB()->getDepA().get(), a.get()); } + +// Test circular dependency detection +class CircularA : public ComponentBase +{ +public: + explicit CircularA(std::shared_ptr b) : depB(b) {} + std::shared_ptr depB; +}; + +class CircularB : public ComponentBase +{ +public: + explicit CircularB(std::shared_ptr a) : depA(a) {} + std::shared_ptr depA; +}; + +TEST_F(DIContainerTest, CircularDependencyDetection) +{ + container.register_component(); + container.register_component(); + + // Should throw with "Circular dependency" message + ASSERT_THROW(container.resolve(), std::runtime_error); +} + +// Test resolve after clear throws +TEST_F(DIContainerTest, ResolveAfterClear) +{ + container.register_component(); + auto a = container.resolve(); + ASSERT_NE(a, nullptr); + + container.clear(); + + // After clear, resolve should throw since component is no longer registered + ASSERT_THROW(container.resolve(), std::runtime_error); +} diff --git a/framework/tests/http_context_multipart_edge_test.cpp b/framework/tests/http_context_multipart_edge_test.cpp new file mode 100644 index 0000000..9dadeca --- /dev/null +++ b/framework/tests/http_context_multipart_edge_test.cpp @@ -0,0 +1,101 @@ +#include "framework/context/http_context.hpp" +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace khttpd_fw = khttpd::framework; + +template +http::request make_request( + http::verb method, + const std::string& target, + int version = 11, + const std::string& body_str = "") +{ + http::request req(method, target, version); + if (!body_str.empty()) + { + req.body() = body_str; + req.prepare_payload(); + } + return req; +} + +class MultipartEdgeTest : public ::testing::Test +{ +}; + +// Test multiple files uploaded under the same form field name +TEST_F(MultipartEdgeTest, MultipleFilesInSameField) +{ + std::string boundary = "----------Boundary123"; + std::string multipart_body = + "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"files\"; filename=\"photo1.jpg\"\r\n" + "Content-Type: image/jpeg\r\n\r\n" + "IMAGE_DATA_1\r\n" + "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"files\"; filename=\"photo2.png\"\r\n" + "Content-Type: image/png\r\n\r\n" + "IMAGE_DATA_2\r\n" + "--" + boundary + "--\r\n"; + + http::request req = make_request(http::verb::post, "/upload", 11, multipart_body); + req.set(http::field::content_type, "multipart/form-data; boundary=" + boundary); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + const auto* files = ctx.get_uploaded_files("files"); + ASSERT_NE(files, nullptr); + ASSERT_EQ(files->size(), 2); + + ASSERT_EQ(files->at(0).filename, "photo1.jpg"); + ASSERT_EQ(files->at(0).content_type, "image/jpeg"); + ASSERT_EQ(files->at(0).data, "IMAGE_DATA_1"); + + ASSERT_EQ(files->at(1).filename, "photo2.png"); + ASSERT_EQ(files->at(1).content_type, "image/png"); + ASSERT_EQ(files->at(1).data, "IMAGE_DATA_2"); +} + +// Test that same form field name results in last value winning (overwrite) +TEST_F(MultipartEdgeTest, FormFieldOverwrite) +{ + std::string boundary = "----------Boundary456"; + std::string multipart_body = + "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"username\"\r\n\r\n" + "first_value\r\n" + "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"username\"\r\n\r\n" + "second_value\r\n" + "--" + boundary + "--\r\n"; + + http::request req = make_request(http::verb::post, "/form", 11, multipart_body); + req.set(http::field::content_type, "multipart/form-data; boundary=" + boundary); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + // Last value should win (overwrite behavior) + ASSERT_TRUE(ctx.get_multipart_field("username").has_value()); + ASSERT_EQ(ctx.get_multipart_field("username").value(), "second_value"); +} + +// Test multipart body without proper boundary - should not crash +TEST_F(MultipartEdgeTest, MissingBoundary) +{ + // Body that claims to be multipart but has no proper boundary markers + std::string boundary = "----------NonExistent"; + std::string body = "This is not a valid multipart body at all"; + + http::request req = make_request(http::verb::post, "/upload", 11, body); + req.set(http::field::content_type, "multipart/form-data; boundary=" + boundary); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + // Should return nullptr/empty results, not crash + const auto* files = ctx.get_uploaded_files("any_field"); + ASSERT_EQ(files, nullptr); + + ASSERT_FALSE(ctx.get_multipart_field("any_field").has_value()); +} diff --git a/framework/tests/router_test.cpp b/framework/tests/router_test.cpp index bcb55bf..f2957ae 100644 --- a/framework/tests/router_test.cpp +++ b/framework/tests/router_test.cpp @@ -607,6 +607,216 @@ TEST(WebsocketRouterTest, NoHandlerRegistered) ASSERT_TRUE(state.mock_session_ptr->last_sent_message.empty()); } +// Test PUT, DELETE, OPTIONS methods +TEST(HttpRouterTest, PutDelOptionsMethods) +{ + khttpd_fw::HttpRouter router; + bool put_called = false; + bool del_called = false; + bool options_called = false; + + router.put("/resource/:id", [&](khttpd_fw::HttpContext& ctx) { + put_called = true; + ctx.set_status(http::status::ok); + }); + router.del("/resource/:id", [&](khttpd_fw::HttpContext& ctx) { + del_called = true; + ctx.set_status(http::status::no_content); + }); + router.options("/resource", [&](khttpd_fw::HttpContext& ctx) { + options_called = true; + ctx.set_status(http::status::ok); + }); + + // Test PUT + { + http::request req = make_request(http::verb::put, "/resource/1"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + router.dispatch(ctx); + ASSERT_TRUE(put_called); + ASSERT_EQ(ctx.get_response().result(), http::status::ok); + } + + // Test DELETE + { + http::request req = make_request(http::verb::delete_, "/resource/2"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + router.dispatch(ctx); + ASSERT_TRUE(del_called); + ASSERT_EQ(ctx.get_response().result(), http::status::no_content); + } + + // Test OPTIONS + { + http::request req = make_request(http::verb::options, "/resource"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + router.dispatch(ctx); + ASSERT_TRUE(options_called); + ASSERT_EQ(ctx.get_response().result(), http::status::ok); + } +} + +// Test route update (re-registering same path) +TEST(HttpRouterTest, RouteUpdate) +{ + khttpd_fw::HttpRouter router; + bool handler_v1_called = false; + bool handler_v2_called = false; + + router.get("/api/v1", [&](khttpd_fw::HttpContext& ctx) { + handler_v1_called = true; + ctx.set_status(http::status::ok); + }); + + // Re-register same path with different handler + router.get("/api/v1", [&](khttpd_fw::HttpContext& ctx) { + handler_v2_called = true; + ctx.set_status(http::status::accepted); + }); + + http::request req = make_request(http::verb::get, "/api/v1"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + router.dispatch(ctx); + + ASSERT_FALSE(handler_v1_called); // Old handler should NOT be called + ASSERT_TRUE(handler_v2_called); // New handler should be called + ASSERT_EQ(ctx.get_response().result(), http::status::accepted); +} + +// Test interceptor integration with dispatch +TEST(HttpRouterTest, InterceptorIntegration) +{ + khttpd_fw::HttpRouter router; + bool handler_called = false; + bool post_called = false; + + class TestInterceptor : public khttpd_fw::Interceptor { + public: + bool* post_ptr; + TestInterceptor(bool* p) : post_ptr(p) {} + khttpd_fw::InterceptorResult handle_request(khttpd_fw::HttpContext& ctx) override { + return khttpd_fw::InterceptorResult::Stop; // Stop processing + } + void handle_response(khttpd_fw::HttpContext& ctx) override { + *post_ptr = true; + ctx.set_header("X-Post-Interceptor", "yes"); + } + }; + + router.add_interceptor(std::make_shared(&post_called)); + + router.get("/stopped", [&](khttpd_fw::HttpContext& ctx) { + handler_called = true; + ctx.set_status(http::status::ok); + }); + + http::request req = make_request(http::verb::get, "/stopped"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + // Simulate HttpSession::handle_request interceptor flow + auto pre_result = router.run_pre_interceptors(ctx); + if (pre_result == khttpd_fw::InterceptorResult::Stop) { + router.run_post_interceptors(ctx); // Post-interceptors still run + } else { + router.dispatch(ctx); + router.run_post_interceptors(ctx); + } + + ASSERT_FALSE(handler_called); // Handler should NOT be called + ASSERT_TRUE(post_called); // Post-interceptor should still run + ASSERT_EQ(ctx.get_response()["X-Post-Interceptor"], "yes"); +} + +// Test exception dispatcher fallthrough when no handler matches +TEST(HttpRouterTest, ExceptionDispatcherFallthrough) +{ + khttpd_fw::HttpRouter router; + auto dispatcher = std::make_shared(); + + bool int_handled = false; + dispatcher->on([&](const int&, khttpd_fw::HttpContext& ctx) { + int_handled = true; + ctx.set_body("int"); + }); + + router.add_exception_handler(dispatcher); + + // Throw a type that no handler registered for + http::request req; + http::response res; + khttpd_fw::HttpContext ctx(req, res); + + double d = 3.14; + router.handle_exception(std::make_exception_ptr(d), ctx); + + ASSERT_FALSE(int_handled); // int handler should NOT be called + // Default fallback should set 500 + ASSERT_EQ(ctx.get_response().result(), http::status::internal_server_error); +} + +// Test wildcard last param with slashes (already tested but add explicit verification) +TEST(HttpRouterTest, WildcardLastParamWithSlashes) +{ + khttpd_fw::HttpRouter router; + std::string captured; + + router.get("/files/:filepath", [&](khttpd_fw::HttpContext& ctx) { + captured = ctx.get_path_param("filepath").value_or(""); + ctx.set_status(http::status::ok); + }); + + http::request req = make_request(http::verb::get, "/files/a/b/c/d.txt"); + http::response res; + khttpd_fw::HttpContext ctx(req, res); + router.dispatch(ctx); + + ASSERT_TRUE(captured == "a/b/c/d.txt"); + ASSERT_EQ(ctx.get_response().result(), http::status::ok); +} + +// Test WebSocket handler overwrite +TEST(WebsocketRouterTest, HandlerOverwrite) +{ + khttpd_fw::WebsocketRouter router; + WsHandlerState state; + std::string test_path = "/overwrite_test"; + + state.mock_session_ptr = std::make_shared(); + std::shared_ptr base_mock_session = + std::static_pointer_cast(state.mock_session_ptr); + + // Register first handler + router.add_handler( + test_path, + [&](khttpd_fw::WebsocketContext& ctx) { + state.on_open_called = true; + state.path_received = "v1"; + }, + nullptr, nullptr, nullptr + ); + + // Register second handler (should overwrite) + router.add_handler( + test_path, + [&](khttpd_fw::WebsocketContext& ctx) { + state.on_open_called = true; + state.path_received = "v2"; + }, + nullptr, nullptr, nullptr + ); + + khttpd_fw::WebsocketContext ctx(base_mock_session, test_path); + router.dispatch_open(test_path, ctx); + + ASSERT_TRUE(state.on_open_called); + ASSERT_EQ(state.path_received, "v2"); // Should use the second handler +} + TEST(WebsocketRouterTest, SpecificHandlerRegistered) { khttpd_fw::WebsocketRouter router; diff --git a/framework/tests/websocket_context_test.cpp b/framework/tests/websocket_context_test.cpp new file mode 100644 index 0000000..9c1c6aa --- /dev/null +++ b/framework/tests/websocket_context_test.cpp @@ -0,0 +1,109 @@ +#include "framework/context/websocket_context.hpp" +#include "framework/websocket/websocket_session.hpp" +#include "framework/router/websocket_router.hpp" +#include +#include +#include + +namespace beast = boost::beast; +namespace ws = beast::websocket; +namespace net = boost::asio; +using tcp = boost::asio::ip::tcp; +namespace khttpd_fw = khttpd::framework; + +// Minimal mock session for WebsocketContext testing +class SimpleMockWsSession : public khttpd_fw::WebsocketSession +{ +public: + static net::io_context& get_dummy_ioc() + { + static net::io_context ioc; + return ioc; + } + static khttpd_fw::WebsocketRouter& get_dummy_router() + { + static khttpd_fw::WebsocketRouter router; + return router; + } + + SimpleMockWsSession() + : WebsocketSession(tcp::socket(get_dummy_ioc()), get_dummy_router(), "/mock") + { + } + + std::string last_sent; + bool last_sent_is_text = false; + + void send_message(const std::string& msg, bool is_text) override + { + last_sent = msg; + last_sent_is_text = is_text; + } +}; + +class WebsocketContextTest : public ::testing::Test +{ +protected: + std::shared_ptr session; + + void SetUp() override + { + session = std::make_shared(); + } +}; + +TEST_F(WebsocketContextTest, Attributes) +{ + khttpd_fw::WebsocketContext ctx( + session, "test message", true, "/test"); + + ctx.set_attribute("user", std::string("alice")); + ctx.set_attribute("count", 42); + + auto user = ctx.get_attribute_as("user"); + ASSERT_TRUE(user.has_value()); + ASSERT_EQ(user.value(), "alice"); + + auto count = ctx.get_attribute_as("count"); + ASSERT_TRUE(count.has_value()); + ASSERT_EQ(count.value(), 42); + + // Missing key + auto missing = ctx.get_attribute_as("missing"); + ASSERT_FALSE(missing.has_value()); + + // Type mismatch + auto wrong = ctx.get_attribute_as("count"); + ASSERT_FALSE(wrong.has_value()); +} + +TEST_F(WebsocketContextTest, SendWithExpiredSession) +{ + khttpd_fw::WebsocketContext ctx( + session, "hello", true, "/test"); + + // Send with valid session + ctx.send("echo back"); + ASSERT_EQ(session->last_sent, "echo back"); + + // Destroy the session + session.reset(); + + // Send with expired session should not crash + ctx.send("should not crash"); + // last_sent remains unchanged since session is gone + ASSERT_EQ(ctx.message, "hello"); // Context still has original message +} + +TEST_F(WebsocketContextTest, ErrorContext) +{ + beast::error_code test_ec = beast::error::timeout; + + khttpd_fw::WebsocketContext ctx( + session, "/test", test_ec); + + ASSERT_EQ(ctx.error_code, test_ec); + ASSERT_EQ(ctx.path, "/test"); + ASSERT_EQ(ctx.is_text, false); // Error context defaults to false + ASSERT_TRUE(ctx.message.empty()); // Message is default constructed empty +} diff --git a/framework/websocket/websocket_session.cpp b/framework/websocket/websocket_session.cpp index bc332d2..8f99204 100644 --- a/framework/websocket/websocket_session.cpp +++ b/framework/websocket/websocket_session.cpp @@ -88,56 +88,39 @@ namespace khttpd::framework void WebsocketSession::send_message(const std::string& msg, bool is_text_msg) { auto ss = std::make_shared(msg); - do_write(ss, is_text_msg); - } - - bool WebsocketSession::send_message(const std::string& id, const std::string& msg, bool is_text) - { - return send_message(std::vector{id}, msg, is_text) > 0; + write_queue_.emplace(ss, is_text_msg); + if (!writing_) + { + writing_ = true; + do_write_next(); + } } - size_t WebsocketSession::send_message(const std::vector& ids, const std::string& msg, bool is_text) + void WebsocketSession::do_write_next() { - std::unique_lock lock{m_sessions_mutex}; - size_t count = 0; - for (const auto& id : ids) + if (write_queue_.empty()) { - auto item = m_sessions_id_.find(id); - if (item == m_sessions_id_.end()) - { - continue; - } - item->second->send_message(msg, is_text); - count++; + writing_ = false; + return; } - return count; - } - void WebsocketSession::do_write(std::shared_ptr ss, bool is_text_msg) - { - // 设置消息是文本还是二进制 + auto item = std::move(write_queue_.front()); + write_queue_.pop(); + auto& ss = item.first; + auto is_text_msg = item.second; + ws_.text(is_text_msg); - // --- 检查消息大小,决定是否分片 --- if (ss->length() < auto_fragment_threshold_) { - // 消息不大,直接发送,无需分片。 - // 这可以避免为小消息创建 vector 和 buffer sequence 的开销。 ws_.async_write(net::buffer(*ss), beast::bind_front_handler(&WebsocketSession::on_write, shared_from_this())); } else { - // 消息很大,需要分片发送。 - // 1. 创建一个缓冲区序列(vector of const_buffer)的 shared_ptr。 - // 必须用 shared_ptr 来管理,因为它需要在异步操作期间保持存活。 auto buffer_sequence_ptr = std::make_shared>(); - - // 2. 预留空间以提高效率 buffer_sequence_ptr->reserve(ss->length() / fragment_size_ + 1); - // 3. 将大字符串切分成多个 buffer,并添加到序列中。 - // 这个过程不会拷贝字符串数据,net::const_buffer 只是一个视图。 size_t offset = 0; while (offset < ss->length()) { @@ -146,20 +129,47 @@ namespace khttpd::framework offset += current_chunk_size; } - // 4. 调用 async_write,传入缓冲区序列。 - // Beast 会自动将序列中的每个 buffer 作为一帧来发送。 ws_.async_write( - *buffer_sequence_ptr, // 传入缓冲区序列 + *buffer_sequence_ptr, [ss, buffer_sequence_ptr, self = shared_from_this()](beast::error_code ec, std::size_t bytes) { - // 这个 lambda 的作用是确保 ss 和 buffer_sequence_ptr 的生命周期 - // 能够覆盖整个异步写操作。当 on_write 被调用时,它们依然有效。 self->on_write(ec, bytes); } ); } } + bool WebsocketSession::send_message(const std::string& id, const std::string& msg, bool is_text) + { + return send_message(std::vector{id}, msg, is_text) > 0; + } + + size_t WebsocketSession::send_message(const std::vector& ids, const std::string& msg, bool is_text) + { + // Collect target session pointers under lock, then release before sending + std::vector> targets; + { + std::unique_lock lock{m_sessions_mutex}; + for (const auto& id : ids) + { + auto item = m_sessions_id_.find(id); + if (item == m_sessions_id_.end()) + { + continue; + } + targets.push_back(item->second); + } + } + // Send messages outside the lock + size_t count = 0; + for (const auto& session : targets) + { + session->send_message(msg, is_text); + count++; + } + return count; + } + void WebsocketSession::on_write(beast::error_code ec, std::size_t bytes_transferred) { boost::ignore_unused(bytes_transferred); @@ -184,16 +194,16 @@ namespace khttpd::framework WebsocketContext close_ctx(shared_from_this(), initial_path_, ec); { std::unique_lock lock{m_sessions_mutex}; - for (auto item = m_sessions_id_.begin(); item != m_sessions_id_.end(); ++item) - { - if (item->first == id) - { - m_sessions_id_.erase(item); - break; - } - } + m_sessions_id_.erase(id); } websocket_router_.dispatch_close(initial_path_, close_ctx); } + // Close the WebSocket stream to properly release the TCP connection + beast::error_code close_ec; + ws_.close(ws::close_code::normal, close_ec); + if (close_ec && close_ec != boost::asio::error::operation_aborted) + { + fmt::print(stderr, "WebSocket close error for path '{}': {}\n", initial_path_, close_ec.message()); + } } } diff --git a/framework/websocket/websocket_session.hpp b/framework/websocket/websocket_session.hpp index a8e88d5..73d93da 100644 --- a/framework/websocket/websocket_session.hpp +++ b/framework/websocket/websocket_session.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "router/websocket_router.hpp" @@ -50,10 +51,14 @@ namespace khttpd::framework // 定义一个阈值,小于这个大小的消息不进行分片,直接发送。 static constexpr size_t const auto_fragment_threshold_ = fragment_size_ * 2; + // Write queue to serialize concurrent async_write calls + std::queue, bool>> write_queue_; + bool writing_ = false; + void on_handshake(beast::error_code ec); void do_read(); void on_read(beast::error_code ec, std::size_t bytes_transferred); - void do_write(std::shared_ptr ss, bool is_text); + void do_write_next(); void on_write(beast::error_code ec, std::size_t bytes_transferred); void do_close(beast::error_code ec = {}); };