commit ed3219deb495875393ba338e6896705c0bc4b51e Author: shay7sev Date: Mon Feb 2 14:27:56 2026 +0800 feat(project): init diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b7c4f6d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[registries.kellnr] +index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/" + +[net] +git-fetch-with-cli = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dea9cfa --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +SERVICE_NAME=cms-service +LOG_LEVEL=info +LOG_TO_FILE=false +LOG_DIR=./log +LOG_FILE_NAME=cms.log + +PORT=3100 + +DATABASE_URL=postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms_service_db +DB_MAX_CONNECTIONS=20 +DB_MIN_CONNECTIONS=5 + +IAM_BASE_URL=http://127.0.0.1:3000 +IAM_JWKS_URL= +JWT_PUBLIC_KEY_PEM= +IAM_TIMEOUT_MS=2000 +IAM_CACHE_TTL_SECONDS=10 +IAM_STALE_IF_ERROR_SECONDS=60 +IAM_CACHE_MAX_ENTRIES=50000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b745e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.env \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..528e7dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [0.1.0] - 2026-01-31 + +- 初始化 cms-service:DDD 目录结构、Telemetry 基础集成、文档与健康检查入口 +- 完成 CMS 核心能力:栏目/文章/媒体/标签分类、草稿发布、版本回滚、分页搜索 +- 复用 IAM:JWT 认证与租户隔离中间件、RBAC 鉴权接口(iam-client + 本地缓存/降级) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f691e64 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auth-kit" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "common-telemetry", + "dashmap", + "http", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "tracing", + "uuid", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cms-service" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth-kit", + "axum", + "chrono", + "common-telemetry", + "config", + "dashmap", + "dotenvy", + "futures-util", + "http", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-scalar", + "uuid", +] + +[[package]] +name = "common-telemetry" +version = "0.1.5" +source = "sparse+https://kellnr.shay7sev.site/api/v1/crates/" +checksum = "5fd21d86f12ac4676934836ff875b59d3ade17d90d1db84a3e2e4f6e259e149b" +dependencies = [ + "anyhow", + "axum", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..299b141 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "cms-service" +version = "0.1.0" +edition = "2024" + +[dependencies] +common-telemetry = { version = "0.1.5", registry = "kellnr", default-features = false, features = [ + "response", + "telemetry", + "with-anyhow", + "with-sqlx", +] } +auth-kit = { path = "../auth-kit" } + +axum = "0.8.8" +http = "1.4.0" +tokio = { version = "1", features = ["full"] } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +sqlx = { version = "0.8", features = [ + "chrono", + "json", + "postgres", + "runtime-tokio-native-tls", + "uuid", + "migrate", +] } + +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4.43", features = ["serde"] } + +config = "0.15.19" +dotenvy = "0.15" + +anyhow = "1" +thiserror = "2.0.18" + +tracing = "0.1" +tracing-subscriber = "0.3" + +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } +utoipa-scalar = { version = "0.3.0", features = ["axum"] } + +futures-util = "0.3" +tower = "0.5" +dashmap = "6.1.0" +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..32c34e3 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# CMS Service + +内容管理系统(CMS)微服务,遵循 DDD 分层(api/application/domain/infrastructure),并与 IAM 服务集成实现“认证 + 租户隔离 + 权限裁决”: + +- 认证/租户隔离:复用 [auth-kit](file:///home/shay/project/backend/auth-kit/README.md) 中间件(JWT 验签 + `X-Tenant-ID` 一致性校验) +- 权限裁决:通过 iam-client 调用 IAM `POST /authorize/check`(CMS 不内置 RBAC 聚合逻辑) + +## 技术栈 + +- Rust:edition 2024 / Tokio +- Web:Axum +- DB:PostgreSQL / SQLx(启动时自动运行 migrations) +- 文档:utoipa + Scalar(`GET /scalar`) +- 观测:tracing + `common-telemetry` +- 鉴权集成: + - JWT:RS256(优先 JWKS 拉取,或配置静态公钥 PEM) + - RBAC:由 IAM 统一裁决 + +## 项目结构 + +DDD 分层目录: + +- [src/api](file:///home/shay/project/backend/cms-service/src/api):HTTP 层(路由/handlers/中间件/OpenAPI) +- [src/application](file:///home/shay/project/backend/cms-service/src/application):应用服务编排(面向用例) +- [src/domain](file:///home/shay/project/backend/cms-service/src/domain):领域模型(实体/DTO) +- [src/infrastructure](file:///home/shay/project/backend/cms-service/src/infrastructure):基础设施(DB、repositories、iam-client) + +关键入口文件: + +- [main.rs](file:///home/shay/project/backend/cms-service/src/main.rs):配置加载、Telemetry、DB 连接、迁移、JWT/Tenant 中间件挂载 +- [api/mod.rs](file:///home/shay/project/backend/cms-service/src/api/mod.rs):路由组装(`/v1`、`/scalar`、`/healthz`) +- [docs/API.md](file:///home/shay/project/backend/cms-service/docs/API.md):接口概览与权限点 + +## 快速开始(本地开发) + +1. 复制并修改环境变量: + - `cp .env.example .env` +2. 准备 PostgreSQL 并配置 `DATABASE_URL` +3. 启动服务(会自动运行 migrations): + - `cargo run` + +## 文档 + +- Scalar:`GET /scalar` +- 健康检查:`GET /healthz` + +## API(v1) + +资源入口: + +- 栏目:`/v1/columns` +- 标签/分类:`/v1/tags` +- 媒体库:`/v1/media` +- 文章:`/v1/articles` + +更完整的接口清单与权限点见: [docs/API.md](file:///home/shay/project/backend/cms-service/docs/API.md) + +## 鉴权与租户隔离 + +- 必须携带: + - `Authorization: Bearer ` + - `X-Tenant-ID: `(与 token 内 tenant_id 不一致将被拒绝) +- CMS 侧 JWT 校验基于 IAM 的公钥: + - 优先读 `JWT_PUBLIC_KEY_PEM`(静态公钥,无需访问 IAM) + - 否则读取 `IAM_JWKS_URL`(未配置则默认 `IAM_BASE_URL + /.well-known/jwks.json`) +- 权限校验由 IAM 统一裁决(CMS 侧仅通过 iam-client 调用 IAM `/authorize/check`)。 + +## 配置项(环境变量) + +基础: + +- `DATABASE_URL`:PostgreSQL 连接串(必填) +- `PORT`:监听端口(默认 3100) +- `SERVICE_NAME` / `LOG_LEVEL` / `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE_NAME`:日志与 Telemetry + +IAM 集成: + +- `IAM_BASE_URL`:IAM 服务地址(默认 `http://localhost:3000`) +- `IAM_JWKS_URL`:JWKS 地址(可选;未配置时使用 `IAM_BASE_URL + /.well-known/jwks.json`) +- `JWT_PUBLIC_KEY_PEM`:静态公钥 PEM(可选;配置后不走 JWKS 拉取) +- `IAM_TIMEOUT_MS`:调用 IAM 超时(默认 2000ms) +- `IAM_CACHE_TTL_SECONDS`:鉴权结果缓存 TTL(默认 10s) +- `IAM_STALE_IF_ERROR_SECONDS`:IAM 不可用时使用 stale cache 的窗口(默认 60s) +- `IAM_CACHE_MAX_ENTRIES`:缓存最大条目数(默认 50000,超过会清空) + +示例配置见: [.env.example](file:///home/shay/project/backend/cms-service/.env.example) + +## 与 IAM 的对接约束 + +CMS 运行时依赖 IAM 提供以下能力: + +- 公钥发布:`GET /.well-known/jwks.json`(用于 RS256 验签;若使用 `JWT_PUBLIC_KEY_PEM` 可不依赖此端点) +- 权限裁决:`POST /authorize/check`(由 iam-client 调用;用于 `cms:*` 权限点校验) + +## 数据库迁移 + +- 迁移文件目录: [migrations](file:///home/shay/project/backend/cms-service/migrations) +- 启动时自动执行:见 [db::run_migrations](file:///home/shay/project/backend/cms-service/src/infrastructure/db/mod.rs#L14-L16) +- 运维脚本(migrate/verify/rollback):见 [scripts/db/README.md](file:///home/shay/project/backend/cms-service/scripts/db/README.md) + +## 测试 + +```bash +cargo test +``` + +当前包含: + +- iam-client 缓存/降级测试: [iam_client_cache.rs](file:///home/shay/project/backend/cms-service/tests/iam_client_cache.rs) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b4d9665 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,49 @@ +# CMS Service API(概览) + +CMS 对外暴露 RESTful API,并提供 Scalar 文档: + +- `GET /scalar` + +## 通用约定 + +- Header: + - `Authorization: Bearer ` + - `X-Tenant-ID: ` +- JWT 校验:默认从 IAM 的 `/.well-known/jwks.json` 获取公钥(也可配置 `JWT_PUBLIC_KEY_PEM` 静态公钥) +- 所有资源均为多租户数据:所有表均包含 `tenant_id` 字段,并在查询/写入时强制按 `tenant_id` 过滤。 +- 权限校验:CMS 侧不实现 RBAC 规则聚合,仅通过 iam-client 调用 IAM `POST /authorize/check` 由 IAM 裁决。 + +## 接口清单(v1) + +### 栏目(Column) + +- `POST /v1/columns`(`cms:column:write`) +- `GET /v1/columns`(`cms:column:read`,分页/搜索) +- `GET /v1/columns/{id}`(`cms:column:read`) +- `PATCH /v1/columns/{id}`(`cms:column:write`) +- `DELETE /v1/columns/{id}`(`cms:column:write`) + +### 标签/分类(Tag) + +- `POST /v1/tags`(`cms:tag:write`,`kind` 支持 `tag|category`) +- `GET /v1/tags`(`cms:tag:read`,分页/搜索/按 kind 过滤) +- `GET /v1/tags/{id}`(`cms:tag:read`) +- `PATCH /v1/tags/{id}`(`cms:tag:write`) +- `DELETE /v1/tags/{id}`(`cms:tag:write`) + +### 媒体库(Media) + +- `POST /v1/media`(`cms:media:manage`,登记 URL/元数据) +- `GET /v1/media`(`cms:media:read`,分页/搜索) +- `GET /v1/media/{id}`(`cms:media:read`) +- `DELETE /v1/media/{id}`(`cms:media:manage`) + +### 文章(Article) + +- `POST /v1/articles`(`cms:article:write`,创建草稿) +- `GET /v1/articles`(`cms:article:read`,分页/搜索/按状态/栏目/标签过滤) +- `GET /v1/articles/{id}`(`cms:article:read`) +- `PATCH /v1/articles/{id}`(`cms:article:write`) +- `POST /v1/articles/{id}/publish`(`cms:article:publish`,发布并生成版本) +- `POST /v1/articles/{id}/rollback`(`cms:article:rollback`,回滚到指定版本并生成新版本) +- `GET /v1/articles/{id}/versions`(`cms:article:read`,版本列表分页) diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md new file mode 100644 index 0000000..a7090b7 --- /dev/null +++ b/docs/TEST_REPORT.md @@ -0,0 +1,18 @@ +# 测试报告(CMS Service) + +## 测试范围 + +- iam-client 适配层: + - 权限校验结果缓存命中 + - IAM 不可用时的 stale-cache 降级 + +## 测试用例 + +- `tests/iam_client_cache.rs` + - `iam_client_caches_decisions` + - `iam_client_uses_stale_cache_on_error` + +## 执行结果 + +- `cargo test`:全部通过 + diff --git a/migrations/0001_core.sql b/migrations/0001_core.sql new file mode 100644 index 0000000..9744c61 --- /dev/null +++ b/migrations/0001_core.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/migrations/0002_cms.sql b/migrations/0002_cms.sql new file mode 100644 index 0000000..2f6c8ce --- /dev/null +++ b/migrations/0002_cms.sql @@ -0,0 +1,90 @@ +CREATE TYPE cms_tag_kind AS ENUM ('tag', 'category'); +CREATE TYPE cms_article_status AS ENUM ('draft', 'published'); + +CREATE TABLE cms_columns ( + tenant_id uuid NOT NULL, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + slug text NOT NULL, + description text, + parent_id uuid, + sort_order int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, slug) +); + +CREATE INDEX cms_columns_tenant_parent_idx ON cms_columns (tenant_id, parent_id); + +CREATE TABLE cms_tags ( + tenant_id uuid NOT NULL, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + kind cms_tag_kind NOT NULL, + name text NOT NULL, + slug text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, kind, slug) +); + +CREATE INDEX cms_tags_tenant_kind_idx ON cms_tags (tenant_id, kind); + +CREATE TABLE cms_media ( + tenant_id uuid NOT NULL, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + url text NOT NULL, + mime_type text, + size_bytes bigint, + width int, + height int, + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid +); + +CREATE INDEX cms_media_tenant_created_at_idx ON cms_media (tenant_id, created_at DESC); + +CREATE TABLE cms_articles ( + tenant_id uuid NOT NULL, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + column_id uuid, + title text NOT NULL, + slug text NOT NULL, + summary text, + content text NOT NULL DEFAULT '', + status cms_article_status NOT NULL DEFAULT 'draft', + current_version int NOT NULL DEFAULT 0, + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by uuid, + updated_by uuid, + UNIQUE (tenant_id, slug) +); + +CREATE INDEX cms_articles_tenant_status_updated_idx ON cms_articles (tenant_id, status, updated_at DESC); +CREATE INDEX cms_articles_tenant_column_idx ON cms_articles (tenant_id, column_id); + +CREATE TABLE cms_article_tags ( + tenant_id uuid NOT NULL, + article_id uuid NOT NULL REFERENCES cms_articles(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES cms_tags(id) ON DELETE CASCADE, + PRIMARY KEY (tenant_id, article_id, tag_id) +); + +CREATE INDEX cms_article_tags_tenant_tag_idx ON cms_article_tags (tenant_id, tag_id); + +CREATE TABLE cms_article_versions ( + tenant_id uuid NOT NULL, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + article_id uuid NOT NULL REFERENCES cms_articles(id) ON DELETE CASCADE, + version int NOT NULL, + title text NOT NULL, + summary text, + content text NOT NULL, + status cms_article_status NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid, + UNIQUE (tenant_id, article_id, version) +); + +CREATE INDEX cms_article_versions_tenant_article_idx ON cms_article_versions (tenant_id, article_id, version DESC); diff --git a/scripts/db/README.md b/scripts/db/README.md new file mode 100644 index 0000000..710b1ee --- /dev/null +++ b/scripts/db/README.md @@ -0,0 +1,69 @@ +# cms-service 数据库脚本(migrate / verify / rollback) + +本目录提供一套与 `iam-service/scripts/db` 类似的数据库操作脚本,适用于: + +- 生产/预发:在启动 cms-service 前,显式执行迁移与校验 +- 开发/测试:快速初始化/回滚 schema + +说明: + +- cms-service 运行时也会自动执行 SQLx migrations(见 `src/infrastructure/db/mod.rs`)。如果你选择使用本目录脚本管理迁移,建议在部署流程中做到“先 migrate,再启动服务”,并在同一数据库上保持一致的迁移源(`cms-service/migrations/*.sql`)。 +- 本脚本会写入 SQLx 使用的 `_sqlx_migrations` 表,使得服务启动时不会重复执行已应用的迁移。 + +## 前置条件 + +- 已安装 `psql` +- `DATABASE_URL` 可用(可通过导出环境变量或在项目根目录 `.env` 中配置) +- 校验/迁移 checksum 需要 `sha384sum` 或 `openssl` + `xxd` + +## 常用命令 + +```bash +export DATABASE_URL='postgres://...' + +# 1) 应用迁移(写入 _sqlx_migrations) +./scripts/db/migrate.sh + +# 2) 校验 schema(包含:迁移校验 + 结构校验) +./scripts/db/verify.sh + +# 3) 回滚到指定版本(例如回滚到 0001) +ROLLBACK_TO_VERSION=1 ./scripts/db/rollback.sh + +# 4) 回滚所有迁移(仅回滚 cms-service 自己的对象;不会卸载 pgcrypto extension) +ROLLBACK_TO_VERSION=0 ./scripts/db/rollback.sh +``` + +## 版本号规则 + +- 迁移文件目录:`cms-service/migrations/*.sql` +- 迁移版本号:取文件名前缀数字(例如 `0002_cms.sql` -> version=2) +- 回滚脚本:`scripts/db/rollback/.down.sql`(例如 `0002.down.sql`) +- 校验脚本:`scripts/db/verify/_*.sql` + +## 故障排查 + +### 启动时报 `failed to run migrations: VersionMismatch()` + +含义: + +- 数据库 `_sqlx_migrations` 表里记录的 `` 号迁移 checksum,和当前仓库里对应 `migrations/_*.sql` 的 checksum 不一致。 +- 常见原因是:迁移文件被改动过、或曾使用非 SQLx 算法写入了 `_sqlx_migrations.checksum`。 + +解决(开发环境推荐做法): + +1) 停止 cms-service +2) 回滚到 0(会删除 cms 表/类型,并清理 `_sqlx_migrations` 中的记录): + +```bash +ROLLBACK_TO_VERSION=0 ./scripts/db/rollback.sh +``` + +3) 重新执行迁移与校验: + +```bash +./scripts/db/migrate.sh +./scripts/db/verify.sh +``` + +然后再启动 cms-service。 diff --git a/scripts/db/migrate.sh b/scripts/db/migrate.sh new file mode 100755 index 0000000..69ae93d --- /dev/null +++ b/scripts/db/migrate.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +MIGRATIONS_DIR="${ROOT_DIR}/migrations" + +load_database_url_from_env_file() { + local env_file="$1" + local line value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + line="${line#export }" + if [[ "${line}" == DATABASE_URL=* ]]; then + value="${line#DATABASE_URL=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "${value}" + return 0 + fi + done < "${env_file}" + return 1 +} + +DATABASE_URL="${DATABASE_URL:-}" +if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then + DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)" +fi +if [[ -z "${DATABASE_URL}" ]]; then + echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)" + exit 1 +fi + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found in PATH" + exit 127 +fi + +checksum_hex_of_file() { + local file="$1" + if command -v sha384sum >/dev/null 2>&1; then + sha384sum "${file}" | awk '{print $1}' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha384 -binary "${file}" | xxd -p -c 256 + return 0 + fi + echo "sha384sum or openssl is required to compute sqlx checksum" >&2 + return 127 +} + +TARGET_VERSION="${TARGET_VERSION:-}" +DRY_RUN="${DRY_RUN:-0}" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)" + +shopt -s nullglob +migrations=( "${MIGRATIONS_DIR}/"*.sql ) +if [[ ${#migrations[@]} -eq 0 ]]; then + echo "No migration files found: ${MIGRATIONS_DIR}/*.sql" + exit 1 +fi + +for file in "${migrations[@]}"; do + base="$(basename "${file}")" + version_str="${base%%_*}" + version_num="$((10#${version_str}))" + description="${base#*_}" + description="${description%.sql}" + + if [[ -n "${TARGET_VERSION}" && "${version_num}" -gt "${TARGET_VERSION}" ]]; then + continue + fi + + applied="$(psql "${DATABASE_URL}" -At -c "SELECT 1 FROM _sqlx_migrations WHERE version=${version_num} AND success=true LIMIT 1" || true)" + if [[ "${applied}" == "1" ]]; then + continue + fi + + echo "Applying ${version_str} (${base})" + if [[ "${DRY_RUN}" == "1" ]]; then + continue + fi + + checksum="$(checksum_hex_of_file "${file}")" + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}" + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO _sqlx_migrations(version, description, success, checksum, execution_time) VALUES (${version_num}, '${description}', true, decode('${checksum}','hex'), 0) ON CONFLICT (version) DO NOTHING" +done + +echo "Migrations completed" diff --git a/scripts/db/rebuild_cms_db.sh b/scripts/db/rebuild_cms_db.sh new file mode 100755 index 0000000..f5b068a --- /dev/null +++ b/scripts/db/rebuild_cms_db.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/reset.sh" +"${SCRIPT_DIR}/migrate.sh" +"${SCRIPT_DIR}/verify.sh" diff --git a/scripts/db/reset.sh b/scripts/db/reset.sh new file mode 100755 index 0000000..63831e2 --- /dev/null +++ b/scripts/db/reset.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ROLLBACK_TO_VERSION=0 "${SCRIPT_DIR}/rollback.sh" diff --git a/scripts/db/rollback.sh b/scripts/db/rollback.sh new file mode 100755 index 0000000..a6da030 --- /dev/null +++ b/scripts/db/rollback.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +ROLLBACK_DIR="${SCRIPT_DIR}/rollback" + +load_database_url_from_env_file() { + local env_file="$1" + local line value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + line="${line#export }" + if [[ "${line}" == DATABASE_URL=* ]]; then + value="${line#DATABASE_URL=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "${value}" + return 0 + fi + done < "${env_file}" + return 1 +} + +DATABASE_URL="${DATABASE_URL:-}" +if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then + DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)" +fi +if [[ -z "${DATABASE_URL}" ]]; then + echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)" + exit 1 +fi + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found in PATH" + exit 127 +fi + +ROLLBACK_TO_VERSION="${ROLLBACK_TO_VERSION:-}" +DRY_RUN="${DRY_RUN:-0}" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)" >/dev/null + +if [[ -z "${ROLLBACK_TO_VERSION}" ]]; then + echo "ROLLBACK_TO_VERSION is required (e.g. 1 or 0)" + exit 1 +fi + +while true; do + current="$(psql "${DATABASE_URL}" -At -c "SELECT version FROM _sqlx_migrations WHERE success=true ORDER BY version DESC LIMIT 1" || true)" + if [[ -z "${current}" ]]; then + echo "No applied migrations found" + exit 0 + fi + + if [[ "${current}" -le "${ROLLBACK_TO_VERSION}" ]]; then + echo "Rollback completed (current=${current}, target=${ROLLBACK_TO_VERSION})" + exit 0 + fi + + file="${ROLLBACK_DIR}/$(printf "%04d" "${current}").down.sql" + if [[ ! -f "${file}" ]]; then + echo "Rollback file not found for version ${current}: ${file}" + exit 1 + fi + + echo "Rolling back version ${current} using $(basename "${file}")" + if [[ "${DRY_RUN}" == "1" ]]; then + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT ${current}" >/dev/null + else + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}" + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DELETE FROM _sqlx_migrations WHERE version=${current}" + fi +done + diff --git a/scripts/db/rollback/0001.down.sql b/scripts/db/rollback/0001.down.sql new file mode 100644 index 0000000..9f5efa4 --- /dev/null +++ b/scripts/db/rollback/0001.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +-- cms-service 0001 仅创建 pgcrypto extension。为避免影响同库其他服务,这里不卸载 extension。 +SELECT 1; + +COMMIT; + diff --git a/scripts/db/rollback/0002.down.sql b/scripts/db/rollback/0002.down.sql new file mode 100644 index 0000000..a7b34da --- /dev/null +++ b/scripts/db/rollback/0002.down.sql @@ -0,0 +1,14 @@ +BEGIN; + +DROP TABLE IF EXISTS cms_article_versions; +DROP TABLE IF EXISTS cms_article_tags; +DROP TABLE IF EXISTS cms_articles; +DROP TABLE IF EXISTS cms_media; +DROP TABLE IF EXISTS cms_tags; +DROP TABLE IF EXISTS cms_columns; + +DROP TYPE IF EXISTS cms_article_status; +DROP TYPE IF EXISTS cms_tag_kind; + +COMMIT; + diff --git a/scripts/db/verify.sh b/scripts/db/verify.sh new file mode 100755 index 0000000..bdd314c --- /dev/null +++ b/scripts/db/verify.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +MIGRATIONS_DIR="${ROOT_DIR}/migrations" +VERIFY_DIR="${SCRIPT_DIR}/verify" + +load_database_url_from_env_file() { + local env_file="$1" + local line value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + line="${line#export }" + if [[ "${line}" == DATABASE_URL=* ]]; then + value="${line#DATABASE_URL=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "${value}" + return 0 + fi + done < "${env_file}" + return 1 +} + +DATABASE_URL="${DATABASE_URL:-}" +if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then + DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)" +fi +if [[ -z "${DATABASE_URL}" ]]; then + echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)" + exit 1 +fi + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found in PATH" + exit 127 +fi + +checksum_hex_of_file() { + local file="$1" + if command -v sha384sum >/dev/null 2>&1; then + sha384sum "${file}" | awk '{print $1}' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha384 -binary "${file}" | xxd -p -c 256 + return 0 + fi + echo "sha384sum or openssl is required to compute sqlx checksum" >&2 + return 127 +} + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)" + +echo "Checking migration checksums in _sqlx_migrations..." + +shopt -s nullglob +migrations=( "${MIGRATIONS_DIR}/"*.sql ) +for file in "${migrations[@]}"; do + base="$(basename "${file}")" + version_str="${base%%_*}" + version_num="$((10#${version_str}))" + expected_checksum="$(checksum_hex_of_file "${file}")" + actual_checksum="$(psql "${DATABASE_URL}" -At -c "SELECT encode(checksum,'hex') FROM _sqlx_migrations WHERE version=${version_num} AND success=true LIMIT 1" || true)" + + if [[ -n "${actual_checksum}" && "${actual_checksum}" != "${expected_checksum}" ]]; then + echo "Checksum mismatch: version=${version_str} expected=${expected_checksum} actual=${actual_checksum}" + exit 2 + fi +done + +echo "Running schema verify scripts..." + +verify_files=( "${VERIFY_DIR}/"*.sql ) +if [[ ${#verify_files[@]} -eq 0 ]]; then + echo "No verify files found: ${VERIFY_DIR}/*.sql" + exit 1 +fi + +for file in "${verify_files[@]}"; do + base="$(basename "${file}")" + echo "Verifying ${base}" + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}" >/dev/null +done + +echo "Verify completed" diff --git a/scripts/db/verify/0001_core.sql b/scripts/db/verify/0001_core.sql new file mode 100644 index 0000000..e5ff02f --- /dev/null +++ b/scripts/db/verify/0001_core.sql @@ -0,0 +1,7 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN + RAISE EXCEPTION 'missing extension: pgcrypto'; + END IF; +END $$; + diff --git a/scripts/db/verify/0002_cms.sql b/scripts/db/verify/0002_cms.sql new file mode 100644 index 0000000..d565229 --- /dev/null +++ b/scripts/db/verify/0002_cms.sql @@ -0,0 +1,29 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cms_tag_kind') THEN + RAISE EXCEPTION 'missing type: cms_tag_kind'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cms_article_status') THEN + RAISE EXCEPTION 'missing type: cms_article_status'; + END IF; + + IF to_regclass('public.cms_columns') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_columns'; + END IF; + IF to_regclass('public.cms_tags') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_tags'; + END IF; + IF to_regclass('public.cms_media') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_media'; + END IF; + IF to_regclass('public.cms_articles') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_articles'; + END IF; + IF to_regclass('public.cms_article_tags') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_article_tags'; + END IF; + IF to_regclass('public.cms_article_versions') IS NULL THEN + RAISE EXCEPTION 'missing table: cms_article_versions'; + END IF; +END $$; + diff --git a/src/api/docs.rs b/src/api/docs.rs new file mode 100644 index 0000000..55d1ffd --- /dev/null +++ b/src/api/docs.rs @@ -0,0 +1,81 @@ +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi + .components + .get_or_insert_with(utoipa::openapi::Components::new); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + } +} + +#[derive(OpenApi)] +#[openapi( + modifiers(&SecurityAddon), + info( + title = "CMS Service API", + version = "0.1.0", + description = include_str!("../../docs/API.md") + ), + paths( + crate::api::handlers::column::create_column_handler, + crate::api::handlers::column::list_columns_handler, + crate::api::handlers::column::get_column_handler, + crate::api::handlers::column::update_column_handler, + crate::api::handlers::column::delete_column_handler, + crate::api::handlers::tag::create_tag_handler, + crate::api::handlers::tag::list_tags_handler, + crate::api::handlers::tag::get_tag_handler, + crate::api::handlers::tag::update_tag_handler, + crate::api::handlers::tag::delete_tag_handler, + crate::api::handlers::media::create_media_handler, + crate::api::handlers::media::list_media_handler, + crate::api::handlers::media::get_media_handler, + crate::api::handlers::media::delete_media_handler, + crate::api::handlers::article::create_article_handler, + crate::api::handlers::article::list_articles_handler, + crate::api::handlers::article::get_article_handler, + crate::api::handlers::article::update_article_handler, + crate::api::handlers::article::publish_article_handler, + crate::api::handlers::article::rollback_article_handler, + crate::api::handlers::article::list_versions_handler + ), + components( + schemas( + crate::api::handlers::column::CreateColumnRequest, + crate::api::handlers::column::UpdateColumnRequest, + crate::api::handlers::tag::CreateTagRequest, + crate::api::handlers::tag::UpdateTagRequest, + crate::api::handlers::media::CreateMediaRequest, + crate::api::handlers::article::CreateArticleRequest, + crate::api::handlers::article::UpdateArticleRequest, + crate::api::handlers::article::RollbackRequest, + crate::domain::models::Column, + crate::domain::models::Tag, + crate::domain::models::Media, + crate::domain::models::Article, + crate::domain::models::ArticleVersion, + crate::infrastructure::repositories::article::ArticleWithTags + ) + ), + tags( + (name = "System", description = "系统:健康检查/文档"), + (name = "Column", description = "栏目管理"), + (name = "Article", description = "文章管理"), + (name = "Media", description = "媒体库"), + (name = "Tag", description = "标签与分类"), + (name = "Version", description = "版本与回滚") + ) +)] +pub struct ApiDoc; diff --git a/src/api/handlers/article.rs b/src/api/handlers/article.rs new file mode 100644 index 0000000..e332409 --- /dev/null +++ b/src/api/handlers/article.rs @@ -0,0 +1,334 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::{get, post}, +}; +use common_telemetry::{AppError, AppResponse}; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::{AppState, handlers::common::extract_bearer_token}; +use auth_kit::middleware::{tenant::TenantId, auth::AuthContext}; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateArticleRequest { + pub column_id: Option, + pub title: String, + pub slug: String, + pub summary: Option, + pub content: String, + pub tag_ids: Option>, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateArticleRequest { + pub column_id: Option>, + pub title: Option, + pub slug: Option, + pub summary: Option>, + pub content: Option, + pub tag_ids: Option>, +} + +#[derive(Debug, serde::Deserialize, IntoParams)] +pub struct ListArticlesQuery { + pub page: Option, + pub page_size: Option, + pub q: Option, + pub status: Option, + pub column_id: Option, + pub tag_id: Option, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct RollbackRequest { + pub to_version: i32, +} + +#[derive(Debug, serde::Deserialize, IntoParams)] +pub struct ListVersionsQuery { + pub page: Option, + pub page_size: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", post(create_article_handler).get(list_articles_handler)) + .route( + "/{id}", + get(get_article_handler).patch(update_article_handler), + ) + .route("/{id}/publish", post(publish_article_handler)) + .route("/{id}/rollback", post(rollback_article_handler)) + .route("/{id}/versions", get(list_versions_handler)) +} + +#[utoipa::path( + post, + path = "/v1/articles", + tag = "Article", + request_body = CreateArticleRequest, + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "创建文章(草稿)", body = crate::infrastructure::repositories::article::ArticleWithTags) + ) +)] +pub async fn create_article_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:write", &token) + .await?; + + let article = state + .services + .create_article( + tenant_id, + body.column_id, + body.title, + body.slug, + body.summary, + body.content, + body.tag_ids.unwrap_or_default(), + Some(user_id), + ) + .await?; + Ok(AppResponse::ok(article)) +} + +#[utoipa::path( + get, + path = "/v1/articles", + tag = "Article", + params(ListArticlesQuery), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "文章列表/搜索", body = crate::infrastructure::repositories::column::Paged) + ) +)] +pub async fn list_articles_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Result>, AppError> +{ + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:read", &token) + .await?; + + let result = state + .services + .list_articles( + tenant_id, + crate::infrastructure::repositories::article::ListArticlesQuery { + page: query.page.unwrap_or(1), + page_size: query.page_size.unwrap_or(20), + q: query.q, + status: query.status, + column_id: query.column_id, + tag_id: query.tag_id, + }, + ) + .await?; + Ok(AppResponse::ok(result)) +} + +#[utoipa::path( + get, + path = "/v1/articles/{id}", + tag = "Article", + params( + ("id" = String, Path, description = "文章ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "文章详情", body = crate::infrastructure::repositories::article::ArticleWithTags) + ) +)] +pub async fn get_article_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:read", &token) + .await?; + + let article = state.services.get_article(tenant_id, id).await?; + Ok(AppResponse::ok(article)) +} + +#[utoipa::path( + patch, + path = "/v1/articles/{id}", + tag = "Article", + request_body = UpdateArticleRequest, + params( + ("id" = String, Path, description = "文章ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "更新文章", body = crate::infrastructure::repositories::article::ArticleWithTags) + ) +)] +pub async fn update_article_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:write", &token) + .await?; + + let article = state + .services + .update_article( + tenant_id, + id, + body.column_id, + body.title, + body.slug, + body.summary, + body.content, + body.tag_ids, + Some(user_id), + ) + .await?; + Ok(AppResponse::ok(article)) +} + +#[utoipa::path( + post, + path = "/v1/articles/{id}/publish", + tag = "Article", + params( + ("id" = String, Path, description = "文章ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "发布文章", body = crate::domain::models::Article) + ) +)] +pub async fn publish_article_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:publish", &token) + .await?; + + let article = state.services.publish_article(tenant_id, id, Some(user_id)).await?; + Ok(AppResponse::ok(article)) +} + +#[utoipa::path( + post, + path = "/v1/articles/{id}/rollback", + tag = "Version", + request_body = RollbackRequest, + params( + ("id" = String, Path, description = "文章ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "回滚到指定版本并生成新版本", body = crate::domain::models::Article) + ) +)] +pub async fn rollback_article_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:rollback", &token) + .await?; + + let article = state + .services + .rollback_article(tenant_id, id, body.to_version, Some(user_id)) + .await?; + Ok(AppResponse::ok(article)) +} + +#[utoipa::path( + get, + path = "/v1/articles/{id}/versions", + tag = "Version", + params( + ("id" = String, Path, description = "文章ID"), + ListVersionsQuery + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "版本列表", body = crate::infrastructure::repositories::column::Paged) + ) +)] +pub async fn list_versions_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Query(query): Query, +) -> Result>, AppError> +{ + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:article:read", &token) + .await?; + + let versions = state + .services + .list_versions( + tenant_id, + id, + query.page.unwrap_or(1), + query.page_size.unwrap_or(20), + ) + .await?; + Ok(AppResponse::ok(versions)) +} diff --git a/src/api/handlers/column.rs b/src/api/handlers/column.rs new file mode 100644 index 0000000..289382b --- /dev/null +++ b/src/api/handlers/column.rs @@ -0,0 +1,247 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::{get, post}, +}; +use common_telemetry::{AppError, AppResponse}; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::{AppState, handlers::common::extract_bearer_token}; +use auth_kit::middleware::{tenant::TenantId, auth::AuthContext}; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateColumnRequest { + pub name: String, + pub slug: String, + pub description: Option, + pub parent_id: Option, + pub sort_order: Option, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateColumnRequest { + pub name: Option, + pub slug: Option, + pub description: Option>, + pub parent_id: Option>, + pub sort_order: Option, +} + +#[derive(Debug, serde::Deserialize, IntoParams)] +pub struct ListColumnsQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, + pub parent_id: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", post(create_column_handler).get(list_columns_handler)) + .route( + "/{id}", + get(get_column_handler) + .patch(update_column_handler) + .delete(delete_column_handler), + ) +} + +#[utoipa::path( + post, + path = "/v1/columns", + tag = "Column", + request_body = CreateColumnRequest, + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "创建栏目", body = crate::domain::models::Column), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ) +)] +pub async fn create_column_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:column:write", &token) + .await?; + + let column = state + .services + .create_column( + tenant_id, + body.name, + body.slug, + body.description, + body.parent_id, + body.sort_order.unwrap_or(0), + ) + .await?; + Ok(AppResponse::ok(column)) +} + +#[utoipa::path( + get, + path = "/v1/columns", + tag = "Column", + params(ListColumnsQuery), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "栏目列表", body = crate::infrastructure::repositories::column::Paged), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ) +)] +pub async fn list_columns_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Result>, AppError> +{ + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:column:read", &token) + .await?; + + let result = state + .services + .list_columns( + tenant_id, + crate::infrastructure::repositories::column::ListColumnsQuery { + page: query.page.unwrap_or(1), + page_size: query.page_size.unwrap_or(20), + search: query.search, + parent_id: query.parent_id, + }, + ) + .await?; + Ok(AppResponse::ok(result)) +} + +#[utoipa::path( + get, + path = "/v1/columns/{id}", + tag = "Column", + params( + ("id" = String, Path, description = "栏目ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "栏目详情", body = crate::domain::models::Column), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "不存在") + ) +)] +pub async fn get_column_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:column:read", &token) + .await?; + + let column = state.services.get_column(tenant_id, id).await?; + Ok(AppResponse::ok(column)) +} + +#[utoipa::path( + patch, + path = "/v1/columns/{id}", + tag = "Column", + request_body = UpdateColumnRequest, + params( + ("id" = String, Path, description = "栏目ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "更新栏目", body = crate::domain::models::Column), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "不存在") + ) +)] +pub async fn update_column_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:column:write", &token) + .await?; + + let column = state + .services + .update_column( + tenant_id, + id, + body.name, + body.slug, + body.description, + body.parent_id, + body.sort_order, + ) + .await?; + Ok(AppResponse::ok(column)) +} + +#[utoipa::path( + delete, + path = "/v1/columns/{id}", + tag = "Column", + params( + ("id" = String, Path, description = "栏目ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "删除成功"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "不存在") + ) +)] +pub async fn delete_column_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:column:write", &token) + .await?; + + state.services.delete_column(tenant_id, id).await?; + Ok(AppResponse::ok(serde_json::json!({"deleted": true}))) +} diff --git a/src/api/handlers/common.rs b/src/api/handlers/common.rs new file mode 100644 index 0000000..e9c2296 --- /dev/null +++ b/src/api/handlers/common.rs @@ -0,0 +1,11 @@ +use axum::http::HeaderMap; +use common_telemetry::AppError; + +pub fn extract_bearer_token(headers: &HeaderMap) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::MissingAuthHeader)?; + Ok(token.to_string()) +} diff --git a/src/api/handlers/media.rs b/src/api/handlers/media.rs new file mode 100644 index 0000000..ffb751d --- /dev/null +++ b/src/api/handlers/media.rs @@ -0,0 +1,175 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::{get, post}, +}; +use common_telemetry::{AppError, AppResponse}; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::{AppState, handlers::common::extract_bearer_token}; +use auth_kit::middleware::{tenant::TenantId, auth::AuthContext}; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateMediaRequest { + pub url: String, + pub mime_type: Option, + pub size_bytes: Option, + pub width: Option, + pub height: Option, +} + +#[derive(Debug, serde::Deserialize, IntoParams)] +pub struct ListMediaQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", post(create_media_handler).get(list_media_handler)) + .route("/{id}", get(get_media_handler).delete(delete_media_handler)) +} + +#[utoipa::path( + post, + path = "/v1/media", + tag = "Media", + request_body = CreateMediaRequest, + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "创建媒体记录", body = crate::domain::models::Media) + ) +)] +pub async fn create_media_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:media:manage", &token) + .await?; + + let media = state + .services + .create_media( + tenant_id, + body.url, + body.mime_type, + body.size_bytes, + body.width, + body.height, + Some(user_id), + ) + .await?; + Ok(AppResponse::ok(media)) +} + +#[utoipa::path( + get, + path = "/v1/media", + tag = "Media", + params(ListMediaQuery), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "媒体列表", body = crate::infrastructure::repositories::column::Paged) + ) +)] +pub async fn list_media_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Result>, AppError> +{ + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:media:read", &token) + .await?; + + let result = state + .services + .list_media( + tenant_id, + crate::infrastructure::repositories::media::ListMediaQuery { + page: query.page.unwrap_or(1), + page_size: query.page_size.unwrap_or(20), + search: query.search, + }, + ) + .await?; + Ok(AppResponse::ok(result)) +} + +#[utoipa::path( + get, + path = "/v1/media/{id}", + tag = "Media", + params( + ("id" = String, Path, description = "媒体ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "媒体详情", body = crate::domain::models::Media) + ) +)] +pub async fn get_media_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:media:read", &token) + .await?; + + let media = state.services.get_media(tenant_id, id).await?; + Ok(AppResponse::ok(media)) +} + +#[utoipa::path( + delete, + path = "/v1/media/{id}", + tag = "Media", + params( + ("id" = String, Path, description = "媒体ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "删除成功") + ) +)] +pub async fn delete_media_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:media:manage", &token) + .await?; + + state.services.delete_media(tenant_id, id).await?; + Ok(AppResponse::ok(serde_json::json!({"deleted": true}))) +} diff --git a/src/api/handlers/mod.rs b/src/api/handlers/mod.rs new file mode 100644 index 0000000..3f9d4bd --- /dev/null +++ b/src/api/handlers/mod.rs @@ -0,0 +1,5 @@ +pub mod article; +pub mod column; +pub mod common; +pub mod media; +pub mod tag; diff --git a/src/api/handlers/tag.rs b/src/api/handlers/tag.rs new file mode 100644 index 0000000..67ffa33 --- /dev/null +++ b/src/api/handlers/tag.rs @@ -0,0 +1,214 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::{get, post}, +}; +use common_telemetry::{AppError, AppResponse}; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::{AppState, handlers::common::extract_bearer_token}; +use auth_kit::middleware::{tenant::TenantId, auth::AuthContext}; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateTagRequest { + pub kind: String, + pub name: String, + pub slug: String, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateTagRequest { + pub name: Option, + pub slug: Option, +} + +#[derive(Debug, serde::Deserialize, IntoParams)] +pub struct ListTagsQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, + pub kind: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", post(create_tag_handler).get(list_tags_handler)) + .route( + "/{id}", + get(get_tag_handler) + .patch(update_tag_handler) + .delete(delete_tag_handler), + ) +} + +#[utoipa::path( + post, + path = "/v1/tags", + tag = "Tag", + request_body = CreateTagRequest, + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "创建标签/分类", body = crate::domain::models::Tag) + ) +)] +pub async fn create_tag_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:tag:write", &token) + .await?; + + let tag = state + .services + .create_tag(tenant_id, body.kind, body.name, body.slug) + .await?; + Ok(AppResponse::ok(tag)) +} + +#[utoipa::path( + get, + path = "/v1/tags", + tag = "Tag", + params(ListTagsQuery), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "标签/分类列表", body = crate::infrastructure::repositories::column::Paged) + ) +)] +pub async fn list_tags_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Result>, AppError> +{ + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:tag:read", &token) + .await?; + + let result = state + .services + .list_tags( + tenant_id, + crate::infrastructure::repositories::tag::ListTagsQuery { + page: query.page.unwrap_or(1), + page_size: query.page_size.unwrap_or(20), + search: query.search, + kind: query.kind, + }, + ) + .await?; + Ok(AppResponse::ok(result)) +} + +#[utoipa::path( + get, + path = "/v1/tags/{id}", + tag = "Tag", + params( + ("id" = String, Path, description = "标签/分类ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "详情", body = crate::domain::models::Tag) + ) +)] +pub async fn get_tag_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:tag:read", &token) + .await?; + + let tag = state.services.get_tag(tenant_id, id).await?; + Ok(AppResponse::ok(tag)) +} + +#[utoipa::path( + patch, + path = "/v1/tags/{id}", + tag = "Tag", + request_body = UpdateTagRequest, + params( + ("id" = String, Path, description = "标签/分类ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "更新", body = crate::domain::models::Tag) + ) +)] +pub async fn update_tag_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:tag:write", &token) + .await?; + + let tag = state + .services + .update_tag(tenant_id, id, body.name, body.slug) + .await?; + Ok(AppResponse::ok(tag)) +} + +#[utoipa::path( + delete, + path = "/v1/tags/{id}", + tag = "Tag", + params( + ("id" = String, Path, description = "标签/分类ID") + ), + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "删除成功") + ) +)] +pub async fn delete_tag_handler( + TenantId(tenant_id): TenantId, + AuthContext { user_id, .. }: AuthContext, + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let token = extract_bearer_token(&headers)?; + state + .iam_client + .require_permission(tenant_id, user_id, "cms:tag:write", &token) + .await?; + + state.services.delete_tag(tenant_id, id).await?; + Ok(AppResponse::ok(serde_json::json!({"deleted": true}))) +} diff --git a/src/api/middleware/mod.rs b/src/api/middleware/mod.rs new file mode 100644 index 0000000..f4f57f0 --- /dev/null +++ b/src/api/middleware/mod.rs @@ -0,0 +1,128 @@ +use axum::{ + extract::{MatchedPath, Request}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use common_telemetry::AppError; +use futures_util::FutureExt; +use http::HeaderValue; +use std::{panic::AssertUnwindSafe, time::Instant}; + +pub async fn ensure_request_id(mut req: Request, next: Next) -> Response { + let request_id = req + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + if let Ok(v) = HeaderValue::from_str(&request_id) { + req.headers_mut().insert("x-request-id", v); + } + + let mut resp = next.run(req).await; + if let Ok(v) = HeaderValue::from_str(&request_id) { + resp.headers_mut().insert("x-request-id", v); + } + resp +} + +pub async fn request_logger(req: Request, next: Next) -> Response { + let started = Instant::now(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + let request_id = req + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let action = req + .extensions() + .get::() + .map(|m| format!("{} {}", method, m.as_str())) + .unwrap_or_else(|| format!("{} {}", method, path)); + + let tenant_id = req + .extensions() + .get::() + .map(|t| t.0.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let user_id = req + .extensions() + .get::() + .map(|c| c.user_id.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let resp = next.run(req).await; + let latency_ms = started.elapsed().as_millis() as u64; + let status = resp.status().as_u16(); + + let error_code = match status { + 200..=399 => "ok", + 400 => "bad_request", + 401 => "unauthorized", + 403 => "permission_denied", + 404 => "not_found", + 409 => "conflict", + 429 => "rate_limited", + 500..=599 => "server_error", + _ => "unknown", + }; + + tracing::info!( + trace_id = %request_id, + tenant_id = %tenant_id, + user_id = %user_id, + action = %action, + latency_ms = latency_ms, + error_code = %error_code, + status = status + ); + + resp +} + +pub async fn catch_panic(req: Request, next: Next) -> Response { + let request_id = req + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + let action = req + .extensions() + .get::() + .map(|m| format!("{} {}", method, m.as_str())) + .unwrap_or_else(|| format!("{} {}", method, path)); + + let tenant_id = req + .extensions() + .get::() + .map(|t| t.0.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let user_id = req + .extensions() + .get::() + .map(|c| c.user_id.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let result = AssertUnwindSafe(next.run(req)).catch_unwind().await; + match result { + Ok(resp) => resp, + Err(_) => { + tracing::error!( + trace_id = %request_id, + tenant_id = %tenant_id, + user_id = %user_id, + action = %action, + latency_ms = 0_u64, + error_code = "panic" + ); + AppError::AnyhowError(anyhow::anyhow!("panic")).into_response() + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..1968205 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,40 @@ +pub mod docs; +pub mod handlers; +pub mod middleware; + +use axum::routing::get; +use axum::Router; +use utoipa::OpenApi; +use utoipa_scalar::{Scalar, Servable}; + +use crate::api::docs::ApiDoc; +use crate::api::middleware::{catch_panic, request_logger}; +use crate::application::services::CmsServices; +use crate::infrastructure::iam_client::IamClient; + +#[derive(Clone)] +pub struct AppState { + pub services: CmsServices, + pub iam_client: IamClient, +} + +pub fn build_router(state: AppState) -> Router { + let health = Router::new().route("/healthz", get(|| async { axum::http::StatusCode::OK })); + + let v1 = Router::new() + .nest("/columns", handlers::column::router()) + .nest("/tags", handlers::tag::router()) + .nest("/media", handlers::media::router()) + .nest("/articles", handlers::article::router()); + + let app = Router::new() + .route("/favicon.ico", get(|| async { axum::http::StatusCode::NO_CONTENT })) + .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) + .merge(health) + .nest("/v1", v1) + .layer(axum::middleware::from_fn(catch_panic)) + .layer(axum::middleware::from_fn(request_logger)) + .with_state(state); + + app +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..4e379ae --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1 @@ +pub mod services; diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs new file mode 100644 index 0000000..7081e19 --- /dev/null +++ b/src/application/services/mod.rs @@ -0,0 +1,250 @@ +use crate::domain::models::{Article, ArticleVersion, Column, Media, Tag}; +use crate::infrastructure::repositories; +use common_telemetry::AppError; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct CmsServices { + pool: PgPool, +} + +impl CmsServices { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn create_column( + &self, + tenant_id: Uuid, + name: String, + slug: String, + description: Option, + parent_id: Option, + sort_order: i32, + ) -> Result { + repositories::column::create_column( + &self.pool, + tenant_id, + name, + slug, + description, + parent_id, + sort_order, + ) + .await + } + + pub async fn list_columns( + &self, + tenant_id: Uuid, + q: repositories::column::ListColumnsQuery, + ) -> Result, AppError> { + repositories::column::list_columns(&self.pool, tenant_id, q).await + } + + pub async fn get_column(&self, tenant_id: Uuid, id: Uuid) -> Result { + repositories::column::get_column(&self.pool, tenant_id, id).await + } + + pub async fn update_column( + &self, + tenant_id: Uuid, + id: Uuid, + name: Option, + slug: Option, + description: Option>, + parent_id: Option>, + sort_order: Option, + ) -> Result { + repositories::column::update_column( + &self.pool, + tenant_id, + id, + name, + slug, + description, + parent_id, + sort_order, + ) + .await + } + + pub async fn delete_column(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + repositories::column::delete_column(&self.pool, tenant_id, id).await + } + + pub async fn create_tag( + &self, + tenant_id: Uuid, + kind: String, + name: String, + slug: String, + ) -> Result { + repositories::tag::create_tag(&self.pool, tenant_id, kind, name, slug).await + } + + pub async fn list_tags( + &self, + tenant_id: Uuid, + q: repositories::tag::ListTagsQuery, + ) -> Result, AppError> { + repositories::tag::list_tags(&self.pool, tenant_id, q).await + } + + pub async fn get_tag(&self, tenant_id: Uuid, id: Uuid) -> Result { + repositories::tag::get_tag(&self.pool, tenant_id, id).await + } + + pub async fn update_tag( + &self, + tenant_id: Uuid, + id: Uuid, + name: Option, + slug: Option, + ) -> Result { + repositories::tag::update_tag(&self.pool, tenant_id, id, name, slug).await + } + + pub async fn delete_tag(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + repositories::tag::delete_tag(&self.pool, tenant_id, id).await + } + + pub async fn create_media( + &self, + tenant_id: Uuid, + url: String, + mime_type: Option, + size_bytes: Option, + width: Option, + height: Option, + created_by: Option, + ) -> Result { + repositories::media::create_media( + &self.pool, + tenant_id, + url, + mime_type, + size_bytes, + width, + height, + created_by, + ) + .await + } + + pub async fn list_media( + &self, + tenant_id: Uuid, + q: repositories::media::ListMediaQuery, + ) -> Result, AppError> { + repositories::media::list_media(&self.pool, tenant_id, q).await + } + + pub async fn get_media(&self, tenant_id: Uuid, id: Uuid) -> Result { + repositories::media::get_media(&self.pool, tenant_id, id).await + } + + pub async fn delete_media(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + repositories::media::delete_media(&self.pool, tenant_id, id).await + } + + pub async fn create_article( + &self, + tenant_id: Uuid, + column_id: Option, + title: String, + slug: String, + summary: Option, + content: String, + tag_ids: Vec, + created_by: Option, + ) -> Result { + repositories::article::create_article( + &self.pool, + tenant_id, + column_id, + title, + slug, + summary, + content, + tag_ids, + created_by, + ) + .await + } + + pub async fn get_article( + &self, + tenant_id: Uuid, + id: Uuid, + ) -> Result { + repositories::article::get_article(&self.pool, tenant_id, id).await + } + + pub async fn list_articles( + &self, + tenant_id: Uuid, + q: repositories::article::ListArticlesQuery, + ) -> Result, AppError> { + repositories::article::list_articles(&self.pool, tenant_id, q).await + } + + pub async fn update_article( + &self, + tenant_id: Uuid, + id: Uuid, + column_id: Option>, + title: Option, + slug: Option, + summary: Option>, + content: Option, + tag_ids: Option>, + updated_by: Option, + ) -> Result { + repositories::article::update_article( + &self.pool, + tenant_id, + id, + column_id, + title, + slug, + summary, + content, + tag_ids, + updated_by, + ) + .await + } + + pub async fn publish_article( + &self, + tenant_id: Uuid, + id: Uuid, + user_id: Option, + ) -> Result { + repositories::article::publish_article(&self.pool, tenant_id, id, user_id).await + } + + pub async fn rollback_article( + &self, + tenant_id: Uuid, + id: Uuid, + to_version: i32, + user_id: Option, + ) -> Result { + repositories::article::rollback_article(&self.pool, tenant_id, id, to_version, user_id) + .await + } + + pub async fn list_versions( + &self, + tenant_id: Uuid, + article_id: Uuid, + page: u32, + page_size: u32, + ) -> Result, AppError> { + repositories::article::list_versions(&self.pool, tenant_id, article_id, page, page_size) + .await + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..c841d0e --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,69 @@ +use std::env; + +#[derive(Clone, Debug)] +pub struct AppConfig { + pub service_name: String, + pub log_level: String, + pub log_to_file: bool, + pub log_dir: String, + pub log_file_name: String, + pub database_url: String, + pub db_max_connections: u32, + pub db_min_connections: u32, + pub port: u16, + pub iam_base_url: String, + pub iam_jwks_url: Option, + pub jwt_public_key_pem: Option, + pub iam_timeout_ms: u64, + pub iam_cache_ttl_seconds: u64, + pub iam_stale_if_error_seconds: u64, + pub iam_cache_max_entries: usize, +} + +impl AppConfig { + pub fn from_env() -> Result { + Ok(Self { + service_name: env::var("SERVICE_NAME").unwrap_or_else(|_| "cms-service".into()), + log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()), + log_to_file: env::var("LOG_TO_FILE") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), + log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()), + log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "cms.log".into()), + database_url: env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL environment variable is required".to_string())?, + db_max_connections: env::var("DB_MAX_CONNECTIONS") + .unwrap_or("20".into()) + .parse() + .map_err(|_| "DB_MAX_CONNECTIONS must be a number".to_string())?, + db_min_connections: env::var("DB_MIN_CONNECTIONS") + .unwrap_or("5".into()) + .parse() + .map_err(|_| "DB_MIN_CONNECTIONS must be a number".to_string())?, + port: env::var("PORT") + .unwrap_or_else(|_| "3100".to_string()) + .parse() + .map_err(|_| "PORT must be a valid number".to_string())?, + iam_base_url: env::var("IAM_BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()), + iam_jwks_url: env::var("IAM_JWKS_URL").ok(), + jwt_public_key_pem: env::var("JWT_PUBLIC_KEY_PEM").ok(), + iam_timeout_ms: env::var("IAM_TIMEOUT_MS") + .unwrap_or_else(|_| "2000".into()) + .parse() + .map_err(|_| "IAM_TIMEOUT_MS must be a number".to_string())?, + iam_cache_ttl_seconds: env::var("IAM_CACHE_TTL_SECONDS") + .unwrap_or_else(|_| "10".into()) + .parse() + .map_err(|_| "IAM_CACHE_TTL_SECONDS must be a number".to_string())?, + iam_stale_if_error_seconds: env::var("IAM_STALE_IF_ERROR_SECONDS") + .unwrap_or_else(|_| "60".into()) + .parse() + .map_err(|_| "IAM_STALE_IF_ERROR_SECONDS must be a number".to_string())?, + iam_cache_max_entries: env::var("IAM_CACHE_MAX_ENTRIES") + .unwrap_or_else(|_| "50000".into()) + .parse() + .map_err(|_| "IAM_CACHE_MAX_ENTRIES must be a number".to_string())?, + }) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..c446ac8 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/src/domain/models.rs b/src/domain/models.rs new file mode 100644 index 0000000..39ea1f7 --- /dev/null +++ b/src/domain/models.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)] +pub struct Column { + pub tenant_id: Uuid, + pub id: Uuid, + pub name: String, + pub slug: String, + pub description: Option, + pub parent_id: Option, + pub sort_order: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)] +pub struct Tag { + pub tenant_id: Uuid, + pub id: Uuid, + pub kind: String, + pub name: String, + pub slug: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)] +pub struct Media { + pub tenant_id: Uuid, + pub id: Uuid, + pub url: String, + pub mime_type: Option, + pub size_bytes: Option, + pub width: Option, + pub height: Option, + pub created_at: chrono::DateTime, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)] +pub struct Article { + pub tenant_id: Uuid, + pub id: Uuid, + pub column_id: Option, + pub title: String, + pub slug: String, + pub summary: Option, + pub content: String, + pub status: String, + pub current_version: i32, + pub published_at: Option>, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub created_by: Option, + pub updated_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)] +pub struct ArticleVersion { + pub tenant_id: Uuid, + pub id: Uuid, + pub article_id: Uuid, + pub version: i32, + pub title: String, + pub summary: Option, + pub content: String, + pub status: String, + pub created_at: chrono::DateTime, + pub created_by: Option, +} diff --git a/src/infrastructure/db/mod.rs b/src/infrastructure/db/mod.rs new file mode 100644 index 0000000..ce7c725 --- /dev/null +++ b/src/infrastructure/db/mod.rs @@ -0,0 +1,16 @@ +use crate::config::AppConfig; +use sqlx::postgres::{PgPool, PgPoolOptions}; +use std::time::Duration; + +pub async fn init_pool(config: &AppConfig) -> Result { + PgPoolOptions::new() + .max_connections(config.db_max_connections) + .min_connections(config.db_min_connections) + .acquire_timeout(Duration::from_secs(3)) + .connect(&config.database_url) + .await +} + +pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!("./migrations").run(pool).await +} diff --git a/src/infrastructure/iam_client/mod.rs b/src/infrastructure/iam_client/mod.rs new file mode 100644 index 0000000..74eecca --- /dev/null +++ b/src/infrastructure/iam_client/mod.rs @@ -0,0 +1,224 @@ +use std::{ + hash::{Hash, Hasher}, + sync::Arc, + time::{Duration, Instant}, +}; + +use common_telemetry::AppError; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct IamClientConfig { + pub base_url: String, + pub timeout: Duration, + pub cache_ttl: Duration, + pub cache_stale_if_error: Duration, + pub cache_max_entries: usize, +} + +#[derive(Clone)] +pub struct IamClient { + inner: Arc, +} + +struct IamClientInner { + http: reqwest::Client, + cfg: IamClientConfig, + cache: DashMap, +} + +#[derive(Clone)] +struct CacheKey { + tenant_id: Uuid, + user_id: Uuid, + permission: String, +} + +impl PartialEq for CacheKey { + fn eq(&self, other: &Self) -> bool { + self.tenant_id == other.tenant_id + && self.user_id == other.user_id + && self.permission == other.permission + } +} +impl Eq for CacheKey {} + +impl Hash for CacheKey { + fn hash(&self, state: &mut H) { + self.tenant_id.hash(state); + self.user_id.hash(state); + self.permission.hash(state); + } +} + +#[derive(Clone)] +struct CacheEntry { + allowed: bool, + expires_at: Instant, + stale_until: Instant, +} + +#[derive(Debug, Serialize)] +struct AuthorizationCheckRequest { + permission: String, +} + +#[derive(Debug, Deserialize)] +struct AuthorizationCheckResponse { + allowed: bool, +} + +#[derive(Debug, Deserialize)] +struct ApiSuccessResponse { + #[allow(dead_code)] + code: u32, + #[allow(dead_code)] + message: String, + data: Option, +} + +impl IamClient { + pub fn new(cfg: IamClientConfig) -> Self { + let http = reqwest::Client::builder() + .timeout(cfg.timeout) + .build() + .expect("failed to build reqwest client"); + Self { + inner: Arc::new(IamClientInner { + http, + cfg, + cache: DashMap::new(), + }), + } + } + + pub async fn require_permission( + &self, + tenant_id: Uuid, + user_id: Uuid, + permission: &str, + access_token: &str, + ) -> Result<(), AppError> { + let allowed = self + .check_permission(tenant_id, user_id, permission, access_token) + .await?; + if allowed { + Ok(()) + } else { + Err(AppError::PermissionDenied(permission.to_string())) + } + } + + async fn check_permission( + &self, + tenant_id: Uuid, + user_id: Uuid, + permission: &str, + access_token: &str, + ) -> Result { + let key = CacheKey { + tenant_id, + user_id, + permission: permission.to_string(), + }; + + let now = Instant::now(); + if let Some(entry) = self.inner.cache.get(&key).map(|e| e.clone()) { + if entry.expires_at > now { + return Ok(entry.allowed); + } + } + + let remote = self + .check_permission_remote(tenant_id, permission, access_token) + .await; + + match remote { + Ok(allowed) => { + self.set_cache(key, allowed); + Ok(allowed) + } + Err(e) => { + if let Some(entry) = self.inner.cache.get(&key).map(|e| e.clone()) { + if entry.stale_until > now { + tracing::warn!( + tenant_id = %tenant_id, + user_id = %user_id, + action = "iam_client.degraded_cache", + latency_ms = 0_u64, + error_code = "iam_degraded_cache" + ); + return Ok(entry.allowed); + } + } + Err(e) + } + } + } + + fn set_cache(&self, key: CacheKey, allowed: bool) { + if self.inner.cache.len() > self.inner.cfg.cache_max_entries { + self.inner.cache.clear(); + } + let now = Instant::now(); + let entry = CacheEntry { + allowed, + expires_at: now + self.inner.cfg.cache_ttl, + stale_until: now + self.inner.cfg.cache_ttl + self.inner.cfg.cache_stale_if_error, + }; + self.inner.cache.insert(key, entry); + } + + async fn check_permission_remote( + &self, + tenant_id: Uuid, + permission: &str, + access_token: &str, + ) -> Result { + let url = format!( + "{}/authorize/check", + self.inner.cfg.base_url.trim_end_matches('/') + ); + + let resp = self + .inner + .http + .post(url) + .bearer_auth(access_token) + .header("X-Tenant-ID", tenant_id.to_string()) + .json(&AuthorizationCheckRequest { + permission: permission.to_string(), + }) + .send() + .await + .map_err(|e| AppError::ExternalReqError(format!("iam:request_failed:{}", e)))?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(AppError::AuthError("iam:unauthorized".into())); + } + if status == reqwest::StatusCode::FORBIDDEN { + return Err(AppError::PermissionDenied("iam:forbidden".into())); + } + if !status.is_success() { + return Err(AppError::ExternalReqError(format!( + "iam:unexpected_status:{}", + status.as_u16() + ))); + } + + let body: ApiSuccessResponse = resp + .json() + .await + .map_err(|e| AppError::ExternalReqError(format!("iam:decode_failed:{}", e)))?; + + let allowed = body + .data + .map(|d| d.allowed) + .ok_or_else(|| AppError::ExternalReqError("iam:missing_data".into()))?; + + Ok(allowed) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..d1299a7 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,3 @@ +pub mod db; +pub mod iam_client; +pub mod repositories; diff --git a/src/infrastructure/repositories/article.rs b/src/infrastructure/repositories/article.rs new file mode 100644 index 0000000..aefce46 --- /dev/null +++ b/src/infrastructure/repositories/article.rs @@ -0,0 +1,480 @@ +use crate::domain::models::{Article, ArticleVersion}; +use common_telemetry::AppError; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct ArticleWithTags { + pub article: Article, + pub tag_ids: Vec, +} + +async fn list_tag_ids_for_article( + tx: &mut Transaction<'_, Postgres>, + tenant_id: Uuid, + article_id: Uuid, +) -> Result, AppError> { + let tags = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT tag_id + FROM cms_article_tags + WHERE tenant_id = $1 AND article_id = $2 + ORDER BY tag_id + "#, + ) + .bind(tenant_id) + .bind(article_id) + .fetch_all(&mut **tx) + .await?; + Ok(tags) +} + +pub async fn create_article( + pool: &PgPool, + tenant_id: Uuid, + column_id: Option, + title: String, + slug: String, + summary: Option, + content: String, + tag_ids: Vec, + created_by: Option, +) -> Result { + let mut tx = pool.begin().await?; + + let article = sqlx::query_as::<_, Article>( + r#" + INSERT INTO cms_articles (tenant_id, column_id, title, slug, summary, content, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + RETURNING + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + "#, + ) + .bind(tenant_id) + .bind(column_id) + .bind(title) + .bind(slug) + .bind(summary) + .bind(content) + .bind(created_by) + .fetch_one(&mut *tx) + .await?; + + for tag_id in &tag_ids { + sqlx::query( + r#" + INSERT INTO cms_article_tags (tenant_id, article_id, tag_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + "#, + ) + .bind(tenant_id) + .bind(article.id) + .bind(tag_id) + .execute(&mut *tx) + .await?; + } + + let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, article.id).await?; + tx.commit().await?; + + Ok(ArticleWithTags { article, tag_ids }) +} + +pub async fn get_article( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + let article = sqlx::query_as::<_, Article>( + r#" + SELECT + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + FROM cms_articles + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(&mut *tx) + .await?; + + let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, id).await?; + tx.commit().await?; + Ok(ArticleWithTags { article, tag_ids }) +} + +pub async fn update_article( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, + column_id: Option>, + title: Option, + slug: Option, + summary: Option>, + content: Option, + tag_ids: Option>, + updated_by: Option, +) -> Result { + let mut tx = pool.begin().await?; + + let article = sqlx::query_as::<_, Article>( + r#" + UPDATE cms_articles + SET + column_id = COALESCE($3, column_id), + title = COALESCE($4, title), + slug = COALESCE($5, slug), + summary = COALESCE($6, summary), + content = COALESCE($7, content), + updated_at = now(), + updated_by = COALESCE($8, updated_by) + WHERE tenant_id = $1 AND id = $2 + RETURNING + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(column_id) + .bind(title) + .bind(slug) + .bind(summary) + .bind(content) + .bind(updated_by) + .fetch_one(&mut *tx) + .await?; + + if let Some(tag_ids) = tag_ids { + sqlx::query( + r#" + DELETE FROM cms_article_tags + WHERE tenant_id = $1 AND article_id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .execute(&mut *tx) + .await?; + + for tag_id in &tag_ids { + sqlx::query( + r#" + INSERT INTO cms_article_tags (tenant_id, article_id, tag_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(tag_id) + .execute(&mut *tx) + .await?; + } + } + + let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, id).await?; + tx.commit().await?; + Ok(ArticleWithTags { article, tag_ids }) +} + +#[derive(Debug, Clone)] +pub struct ListArticlesQuery { + pub page: u32, + pub page_size: u32, + pub q: Option, + pub status: Option, + pub column_id: Option, + pub tag_id: Option, +} + +pub async fn list_articles( + pool: &PgPool, + tenant_id: Uuid, + q: ListArticlesQuery, +) -> Result, AppError> { + let page = q.page.max(1); + let page_size = q.page_size.clamp(1, 200); + let offset = ((page - 1) * page_size) as i64; + let limit = page_size as i64; + + let like = q.q.map(|s| format!("%{}%", s)); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT a.id) + FROM cms_articles a + LEFT JOIN cms_article_tags at ON at.tenant_id = a.tenant_id AND at.article_id = a.id + WHERE a.tenant_id = $1 + AND ($2::cms_article_status IS NULL OR a.status = $2::cms_article_status) + AND ($3::uuid IS NULL OR a.column_id = $3) + AND ($4::uuid IS NULL OR at.tag_id = $4) + AND ($5::text IS NULL OR a.title ILIKE $5 OR a.slug ILIKE $5 OR COALESCE(a.summary, '') ILIKE $5) + "#, + ) + .bind(tenant_id) + .bind(q.status.as_deref()) + .bind(q.column_id) + .bind(q.tag_id) + .bind(like.as_deref()) + .fetch_one(pool) + .await?; + + let items = sqlx::query_as::<_, Article>( + r#" + SELECT + a.tenant_id, a.id, a.column_id, a.title, a.slug, a.summary, a.content, + a.status::text as status, a.current_version, a.published_at, + a.created_at, a.updated_at, a.created_by, a.updated_by + FROM cms_articles a + WHERE a.tenant_id = $1 + AND ($2::cms_article_status IS NULL OR a.status = $2::cms_article_status) + AND ($3::uuid IS NULL OR a.column_id = $3) + AND ($5::text IS NULL OR a.title ILIKE $5 OR a.slug ILIKE $5 OR COALESCE(a.summary, '') ILIKE $5) + AND ( + $4::uuid IS NULL OR EXISTS ( + SELECT 1 FROM cms_article_tags at + WHERE at.tenant_id = a.tenant_id AND at.article_id = a.id AND at.tag_id = $4 + ) + ) + ORDER BY a.updated_at DESC + OFFSET $6 + LIMIT $7 + "#, + ) + .bind(tenant_id) + .bind(q.status.as_deref()) + .bind(q.column_id) + .bind(q.tag_id) + .bind(like.as_deref()) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(super::column::Paged { + items, + page, + page_size, + total, + }) +} + +pub async fn publish_article( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, + user_id: Option, +) -> Result { + let mut tx = pool.begin().await?; + + let article = sqlx::query_as::<_, Article>( + r#" + SELECT + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + FROM cms_articles + WHERE tenant_id = $1 AND id = $2 + FOR UPDATE + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(&mut *tx) + .await?; + + let next_version = article.current_version + 1; + + sqlx::query( + r#" + INSERT INTO cms_article_versions (tenant_id, article_id, version, title, summary, content, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7::cms_article_status, $8) + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(next_version) + .bind(&article.title) + .bind(&article.summary) + .bind(&article.content) + .bind("published") + .bind(user_id) + .execute(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, Article>( + r#" + UPDATE cms_articles + SET + status = 'published', + current_version = $3, + published_at = COALESCE(published_at, now()), + updated_at = now(), + updated_by = COALESCE($4, updated_by) + WHERE tenant_id = $1 AND id = $2 + RETURNING + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(next_version) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok(updated) +} + +pub async fn rollback_article( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, + to_version: i32, + user_id: Option, +) -> Result { + let mut tx = pool.begin().await?; + + let target = sqlx::query_as::<_, ArticleVersion>( + r#" + SELECT + tenant_id, id, article_id, version, + title, summary, content, status::text as status, + created_at, created_by + FROM cms_article_versions + WHERE tenant_id = $1 AND article_id = $2 AND version = $3 + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(to_version) + .fetch_one(&mut *tx) + .await?; + + let current_version: i32 = sqlx::query_scalar( + r#" + SELECT current_version + FROM cms_articles + WHERE tenant_id = $1 AND id = $2 + FOR UPDATE + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(&mut *tx) + .await?; + + let next_version = current_version + 1; + + let updated = sqlx::query_as::<_, Article>( + r#" + UPDATE cms_articles + SET + title = $3, + summary = $4, + content = $5, + status = $6::cms_article_status, + current_version = $7, + updated_at = now(), + updated_by = COALESCE($8, updated_by) + WHERE tenant_id = $1 AND id = $2 + RETURNING + tenant_id, id, column_id, title, slug, summary, content, + status::text as status, current_version, published_at, + created_at, updated_at, created_by, updated_by + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(&target.title) + .bind(&target.summary) + .bind(&target.content) + .bind(&target.status) + .bind(next_version) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO cms_article_versions (tenant_id, article_id, version, title, summary, content, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7::cms_article_status, $8) + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(next_version) + .bind(&updated.title) + .bind(&updated.summary) + .bind(&updated.content) + .bind(&updated.status) + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(updated) +} + +pub async fn list_versions( + pool: &PgPool, + tenant_id: Uuid, + article_id: Uuid, + page: u32, + page_size: u32, +) -> Result, AppError> { + let page = page.max(1); + let page_size = page_size.clamp(1, 200); + let offset = ((page - 1) * page_size) as i64; + let limit = page_size as i64; + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM cms_article_versions + WHERE tenant_id = $1 AND article_id = $2 + "#, + ) + .bind(tenant_id) + .bind(article_id) + .fetch_one(pool) + .await?; + + let items = sqlx::query_as::<_, ArticleVersion>( + r#" + SELECT + tenant_id, id, article_id, version, + title, summary, content, status::text as status, + created_at, created_by + FROM cms_article_versions + WHERE tenant_id = $1 AND article_id = $2 + ORDER BY version DESC + OFFSET $3 + LIMIT $4 + "#, + ) + .bind(tenant_id) + .bind(article_id) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(super::column::Paged { + items, + page, + page_size, + total, + }) +} diff --git a/src/infrastructure/repositories/column.rs b/src/infrastructure/repositories/column.rs new file mode 100644 index 0000000..7df3fd9 --- /dev/null +++ b/src/infrastructure/repositories/column.rs @@ -0,0 +1,174 @@ +use crate::domain::models::Column; +use common_telemetry::AppError; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn create_column( + pool: &PgPool, + tenant_id: Uuid, + name: String, + slug: String, + description: Option, + parent_id: Option, + sort_order: i32, +) -> Result { + let column = sqlx::query_as::<_, Column>( + r#" + INSERT INTO cms_columns (tenant_id, name, slug, description, parent_id, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at + "#, + ) + .bind(tenant_id) + .bind(name) + .bind(slug) + .bind(description) + .bind(parent_id) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(column) +} + +pub async fn get_column(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result { + let column = sqlx::query_as::<_, Column>( + r#" + SELECT tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at + FROM cms_columns + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(pool) + .await?; + + Ok(column) +} + +pub async fn update_column( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, + name: Option, + slug: Option, + description: Option>, + parent_id: Option>, + sort_order: Option, +) -> Result { + let column = sqlx::query_as::<_, Column>( + r#" + UPDATE cms_columns + SET + name = COALESCE($3, name), + slug = COALESCE($4, slug), + description = COALESCE($5, description), + parent_id = COALESCE($6, parent_id), + sort_order = COALESCE($7, sort_order), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(name) + .bind(slug) + .bind(description) + .bind(parent_id) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(column) +} + +pub async fn delete_column(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + let res = sqlx::query( + r#" + DELETE FROM cms_columns + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .execute(pool) + .await?; + + if res.rows_affected() == 0 { + return Err(AppError::NotFound("column:not_found".into())); + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ListColumnsQuery { + pub page: u32, + pub page_size: u32, + pub search: Option, + pub parent_id: Option, +} + +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct Paged { + pub items: Vec, + pub page: u32, + pub page_size: u32, + pub total: i64, +} + +pub async fn list_columns( + pool: &PgPool, + tenant_id: Uuid, + q: ListColumnsQuery, +) -> Result, AppError> { + let page = q.page.max(1); + let page_size = q.page_size.clamp(1, 200); + let offset = ((page - 1) * page_size) as i64; + let limit = page_size as i64; + + let like = q.search.map(|s| format!("%{}%", s)); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM cms_columns + WHERE tenant_id = $1 + AND ($2::uuid IS NULL OR parent_id = $2) + AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3) + "#, + ) + .bind(tenant_id) + .bind(q.parent_id) + .bind(like.as_deref()) + .fetch_one(pool) + .await?; + + let items = sqlx::query_as::<_, Column>( + r#" + SELECT tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at + FROM cms_columns + WHERE tenant_id = $1 + AND ($2::uuid IS NULL OR parent_id = $2) + AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3) + ORDER BY sort_order ASC, updated_at DESC + OFFSET $4 + LIMIT $5 + "#, + ) + .bind(tenant_id) + .bind(q.parent_id) + .bind(like.as_deref()) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(Paged { + items, + page, + page_size, + total, + }) +} diff --git a/src/infrastructure/repositories/media.rs b/src/infrastructure/repositories/media.rs new file mode 100644 index 0000000..c4b51a3 --- /dev/null +++ b/src/infrastructure/repositories/media.rs @@ -0,0 +1,124 @@ +use crate::domain::models::Media; +use common_telemetry::AppError; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn create_media( + pool: &PgPool, + tenant_id: Uuid, + url: String, + mime_type: Option, + size_bytes: Option, + width: Option, + height: Option, + created_by: Option, +) -> Result { + let media = sqlx::query_as::<_, Media>( + r#" + INSERT INTO cms_media (tenant_id, url, mime_type, size_bytes, width, height, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by + "#, + ) + .bind(tenant_id) + .bind(url) + .bind(mime_type) + .bind(size_bytes) + .bind(width) + .bind(height) + .bind(created_by) + .fetch_one(pool) + .await?; + Ok(media) +} + +pub async fn get_media(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result { + let media = sqlx::query_as::<_, Media>( + r#" + SELECT tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by + FROM cms_media + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(pool) + .await?; + Ok(media) +} + +pub async fn delete_media(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + let res = sqlx::query( + r#" + DELETE FROM cms_media + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .execute(pool) + .await?; + + if res.rows_affected() == 0 { + return Err(AppError::NotFound("media:not_found".into())); + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ListMediaQuery { + pub page: u32, + pub page_size: u32, + pub search: Option, +} + +pub async fn list_media( + pool: &PgPool, + tenant_id: Uuid, + q: ListMediaQuery, +) -> Result, AppError> { + let page = q.page.max(1); + let page_size = q.page_size.clamp(1, 200); + let offset = ((page - 1) * page_size) as i64; + let limit = page_size as i64; + + let like = q.search.map(|s| format!("%{}%", s)); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM cms_media + WHERE tenant_id = $1 + AND ($2::text IS NULL OR url ILIKE $2) + "#, + ) + .bind(tenant_id) + .bind(like.as_deref()) + .fetch_one(pool) + .await?; + + let items = sqlx::query_as::<_, Media>( + r#" + SELECT tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by + FROM cms_media + WHERE tenant_id = $1 + AND ($2::text IS NULL OR url ILIKE $2) + ORDER BY created_at DESC + OFFSET $3 + LIMIT $4 + "#, + ) + .bind(tenant_id) + .bind(like.as_deref()) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(super::column::Paged { + items, + page, + page_size, + total, + }) +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs new file mode 100644 index 0000000..442608d --- /dev/null +++ b/src/infrastructure/repositories/mod.rs @@ -0,0 +1,4 @@ +pub mod article; +pub mod column; +pub mod media; +pub mod tag; diff --git a/src/infrastructure/repositories/tag.rs b/src/infrastructure/repositories/tag.rs new file mode 100644 index 0000000..26fa909 --- /dev/null +++ b/src/infrastructure/repositories/tag.rs @@ -0,0 +1,150 @@ +use crate::domain::models::Tag; +use common_telemetry::AppError; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn create_tag( + pool: &PgPool, + tenant_id: Uuid, + kind: String, + name: String, + slug: String, +) -> Result { + let tag = sqlx::query_as::<_, Tag>( + r#" + INSERT INTO cms_tags (tenant_id, kind, name, slug) + VALUES ($1, $2::cms_tag_kind, $3, $4) + RETURNING tenant_id, id, kind::text as kind, name, slug, created_at, updated_at + "#, + ) + .bind(tenant_id) + .bind(kind) + .bind(name) + .bind(slug) + .fetch_one(pool) + .await?; + Ok(tag) +} + +pub async fn get_tag(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result { + let tag = sqlx::query_as::<_, Tag>( + r#" + SELECT tenant_id, id, kind::text as kind, name, slug, created_at, updated_at + FROM cms_tags + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .fetch_one(pool) + .await?; + Ok(tag) +} + +pub async fn update_tag( + pool: &PgPool, + tenant_id: Uuid, + id: Uuid, + name: Option, + slug: Option, +) -> Result { + let tag = sqlx::query_as::<_, Tag>( + r#" + UPDATE cms_tags + SET + name = COALESCE($3, name), + slug = COALESCE($4, slug), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING tenant_id, id, kind::text as kind, name, slug, created_at, updated_at + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(name) + .bind(slug) + .fetch_one(pool) + .await?; + Ok(tag) +} + +pub async fn delete_tag(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> { + let res = sqlx::query( + r#" + DELETE FROM cms_tags + WHERE tenant_id = $1 AND id = $2 + "#, + ) + .bind(tenant_id) + .bind(id) + .execute(pool) + .await?; + + if res.rows_affected() == 0 { + return Err(AppError::NotFound("tag:not_found".into())); + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ListTagsQuery { + pub page: u32, + pub page_size: u32, + pub search: Option, + pub kind: Option, +} + +pub async fn list_tags( + pool: &PgPool, + tenant_id: Uuid, + q: ListTagsQuery, +) -> Result, AppError> { + let page = q.page.max(1); + let page_size = q.page_size.clamp(1, 200); + let offset = ((page - 1) * page_size) as i64; + let limit = page_size as i64; + + let like = q.search.map(|s| format!("%{}%", s)); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM cms_tags + WHERE tenant_id = $1 + AND ($2::cms_tag_kind IS NULL OR kind = $2::cms_tag_kind) + AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3) + "#, + ) + .bind(tenant_id) + .bind(q.kind.as_deref()) + .bind(like.as_deref()) + .fetch_one(pool) + .await?; + + let items = sqlx::query_as::<_, Tag>( + r#" + SELECT tenant_id, id, kind::text as kind, name, slug, created_at, updated_at + FROM cms_tags + WHERE tenant_id = $1 + AND ($2::cms_tag_kind IS NULL OR kind = $2::cms_tag_kind) + AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3) + ORDER BY updated_at DESC + OFFSET $4 + LIMIT $5 + "#, + ) + .bind(tenant_id) + .bind(q.kind.as_deref()) + .bind(like.as_deref()) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(super::column::Paged { + items, + page, + page_size, + total, + }) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..edd3285 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod api; +pub mod application; +pub mod config; +pub mod domain; +pub mod infrastructure; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9f0279e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,85 @@ +use axum::middleware::{from_fn, from_fn_with_state}; +use cms_service::{ + api::{self, AppState}, + application::services::CmsServices, + config::AppConfig, + infrastructure::{db, iam_client::{IamClient, IamClientConfig}}, +}; +use common_telemetry::telemetry::{self, TelemetryConfig}; +use auth_kit::middleware::{tenant::TenantMiddlewareConfig, auth::AuthMiddlewareConfig}; +use std::net::SocketAddr; +use std::time::Duration; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + + let config = AppConfig::from_env().expect("failed to load config"); + let _guard = telemetry::init(TelemetryConfig { + service_name: config.service_name.clone(), + log_level: config.log_level.clone(), + log_to_file: config.log_to_file, + log_dir: Some(config.log_dir.clone()), + log_file: Some(config.log_file_name.clone()), + }); + + let pool = db::init_pool(&config).await.expect("failed to init db pool"); + db::run_migrations(&pool) + .await + .expect("failed to run migrations"); + + let state = AppState { + services: CmsServices::new(pool), + iam_client: IamClient::new(IamClientConfig { + base_url: config.iam_base_url.clone(), + timeout: Duration::from_millis(config.iam_timeout_ms), + cache_ttl: Duration::from_secs(config.iam_cache_ttl_seconds), + cache_stale_if_error: Duration::from_secs(config.iam_stale_if_error_seconds), + cache_max_entries: config.iam_cache_max_entries, + }), + }; + + let auth_cfg = AuthMiddlewareConfig { + skip_exact_paths: vec!["/healthz".to_string()], + skip_path_prefixes: vec!["/scalar".to_string()], + jwt: match &config.jwt_public_key_pem { + Some(pem) => auth_kit::jwt::JwtVerifyConfig::rs256_from_pem("iam-service", pem) + .expect("invalid JWT_PUBLIC_KEY_PEM"), + None => { + let jwks_url = config.iam_jwks_url.clone().unwrap_or_else(|| { + format!( + "{}/.well-known/jwks.json", + config.iam_base_url.trim_end_matches('/') + ) + }); + auth_kit::jwt::JwtVerifyConfig::rs256_from_jwks("iam-service", &jwks_url) + .expect("invalid IAM_JWKS_URL") + } + }, + }; + let tenant_cfg = TenantMiddlewareConfig { + skip_exact_paths: vec!["/healthz".to_string()], + skip_path_prefixes: vec!["/scalar".to_string()], + }; + + let app = api::build_router(state) + .layer(from_fn_with_state( + tenant_cfg, + auth_kit::middleware::tenant::resolve_tenant_with_config, + )) + .layer(from_fn_with_state( + auth_cfg, + auth_kit::middleware::auth::authenticate_with_config, + )) + .layer(from_fn(common_telemetry::axum_middleware::trace_http_request)) + .layer(from_fn(cms_service::api::middleware::ensure_request_id)); + + let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); + tracing::info!("Server started at http://{}", addr); + tracing::info!("Docs available at http://{}/scalar", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await + .unwrap(); +} diff --git a/tests/iam_client_cache.rs b/tests/iam_client_cache.rs new file mode 100644 index 0000000..22b3b21 --- /dev/null +++ b/tests/iam_client_cache.rs @@ -0,0 +1,128 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use std::time::Duration; + +use axum::{Json, Router, routing::post}; +use axum::response::IntoResponse; +use cms_service::infrastructure::iam_client::{IamClient, IamClientConfig}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +struct AuthorizationCheckRequest { + permission: String, +} + +#[derive(Debug, Serialize)] +struct AuthorizationCheckResponse { + allowed: bool, +} + +#[derive(Debug, Serialize)] +struct ApiSuccessResponse { + code: u32, + message: String, + data: T, + trace_id: Option, +} + +async fn start_mock_iam( + call_count: Arc, + fail: Arc, +) -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route( + "/authorize/check", + post(move |Json(body): Json| { + let call_count = call_count.clone(); + let fail = fail.clone(); + async move { + call_count.fetch_add(1, Ordering::SeqCst); + if fail.load(Ordering::SeqCst) { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "fail").into_response(); + } + + let allowed = body.permission == "cms:article:read"; + let resp = ApiSuccessResponse { + code: 0, + message: "ok".to_string(), + data: AuthorizationCheckResponse { allowed }, + trace_id: None, + }; + (axum::http::StatusCode::OK, Json(resp)).into_response() + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (base_url, handle) +} + +#[tokio::test] +async fn iam_client_caches_decisions() { + let call_count = Arc::new(AtomicUsize::new(0)); + let fail = Arc::new(AtomicBool::new(false)); + let (base_url, handle) = start_mock_iam(call_count.clone(), fail.clone()).await; + + let client = IamClient::new(IamClientConfig { + base_url, + timeout: Duration::from_millis(500), + cache_ttl: Duration::from_secs(5), + cache_stale_if_error: Duration::from_secs(30), + cache_max_entries: 1000, + }); + + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + client + .require_permission(tenant_id, user_id, "cms:article:read", "token") + .await + .unwrap(); + client + .require_permission(tenant_id, user_id, "cms:article:read", "token") + .await + .unwrap(); + + assert_eq!(call_count.load(Ordering::SeqCst), 1); + handle.abort(); +} + +#[tokio::test] +async fn iam_client_uses_stale_cache_on_error() { + let call_count = Arc::new(AtomicUsize::new(0)); + let fail = Arc::new(AtomicBool::new(false)); + let (base_url, handle) = start_mock_iam(call_count.clone(), fail.clone()).await; + + let client = IamClient::new(IamClientConfig { + base_url, + timeout: Duration::from_millis(500), + cache_ttl: Duration::from_millis(50), + cache_stale_if_error: Duration::from_secs(30), + cache_max_entries: 1000, + }); + + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + client + .require_permission(tenant_id, user_id, "cms:article:read", "token") + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(70)).await; + fail.store(true, Ordering::SeqCst); + + client + .require_permission(tenant_id, user_id, "cms:article:read", "token") + .await + .unwrap(); + + assert!(call_count.load(Ordering::SeqCst) >= 1); + handle.abort(); +}