diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e9c44..0117204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,22 @@ jobs: rust: name: Rust Checks runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: rmcp_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d rmcp_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/rmcp_test steps: - name: Checkout diff --git a/Cargo.lock b/Cargo.lock index ddb72cf..85ab3c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -80,24 +101,190 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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 = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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 = "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-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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "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 = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -114,6 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -122,6 +310,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -135,17 +346,100 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "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", + "libc", + "wasi", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "http" version = "1.4.0" @@ -227,6 +521,108 @@ dependencies = [ "tower-service", ] +[[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" @@ -234,7 +630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -255,6 +651,33 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[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" @@ -358,7 +781,9 @@ dependencies = [ "mcpaas-schema", "serde", "serde_json", + "sqlx", "thiserror", + "tokio", ] [[package]] @@ -387,6 +812,16 @@ dependencies = [ "thiserror", ] +[[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.8.0" @@ -407,7 +842,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -416,7 +851,16 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -425,6 +869,35 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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 = "percent-encoding" version = "2.3.2" @@ -443,6 +916,30 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[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" @@ -461,6 +958,54 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -478,12 +1023,66 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +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 = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -563,6 +1162,17 @@ dependencies = [ "unsafe-libyaml", ] +[[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" @@ -572,6 +1182,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.12" @@ -583,6 +1199,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -591,9 +1210,152 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[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-postgres", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[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-postgres", + "syn", + "tokio", + "url", +] + +[[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", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[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.117" @@ -611,6 +1373,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[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 = "thiserror" version = "2.0.18" @@ -640,18 +1413,44 @@ dependencies = [ "cfg-if", ] +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -665,6 +1464,17 @@ dependencies = [ "syn", ] +[[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 = "tower" version = "0.5.3" @@ -755,36 +1565,154 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[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.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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 = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[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 = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +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-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -794,6 +1722,236 @@ 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", + "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_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_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_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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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_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_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_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 = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[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.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +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.21" diff --git a/Cargo.toml b/Cargo.toml index dd961d8..ac4f785 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" diff --git a/TASKS.md b/TASKS.md index a873576..7143f3d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,32 +2,24 @@ ## Current -### `feat/schema-engine` +### `feat/registry-storage` Status: completed DoD: -- schema model exists -- schema validation works -- JSON sample normalization works -- protobuf -> schema bridge contracts exist +- migrations create tables from `database-schema` +- version snapshots persist and load correctly +- publish linkage works +- auth profiles and artifact metadata are stored +- registry integration tests run on a real SQLite database ## Next -### `feat/mapping-engine` - -Status: pending - -DoD: - -- JSONPath parsing works -- input and output mapping work -- draft generation from samples works +- `feat/rest-vertical-slice` ## Backlog -- `feat/registry-storage` - `feat/rest-vertical-slice` - `feat/admin-api-v1` - `feat/ui-v1` diff --git a/crates/mcpaas-mapping/src/error.rs b/crates/mcpaas-mapping/src/error.rs new file mode 100644 index 0000000..22c30c9 --- /dev/null +++ b/crates/mcpaas-mapping/src/error.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum MappingError { + #[error("invalid JSONPath: {path}")] + InvalidJsonPath { path: String }, + #[error("unsupported source root {root} for {context} mapping")] + UnsupportedSourceRoot { root: String, context: &'static str }, + #[error("unsupported target root {root} for {context} mapping")] + UnsupportedTargetRoot { root: String, context: &'static str }, + #[error("mixed mapping target contexts are not allowed")] + MixedTargetContext, + #[error("missing required value at {path}")] + MissingRequiredValue { path: String }, + #[error("transform {transform} cannot be applied to {actual}")] + InvalidTransformInput { + transform: &'static str, + actual: &'static str, + }, +} diff --git a/crates/mcpaas-mapping/src/execute.rs b/crates/mcpaas-mapping/src/execute.rs new file mode 100644 index 0000000..0a91aaf --- /dev/null +++ b/crates/mcpaas-mapping/src/execute.rs @@ -0,0 +1,385 @@ +use serde_json::{Map, Number, Value}; + +use crate::{ + JsonPath, JsonPathSegment, MappingError, MappingSet, MappingTargetContext, TransformKind, +}; + +impl MappingSet { + pub fn apply(&self, source: &Value) -> Result { + self.validate_paths()?; + + let mut target = empty_target_context(self.target_context()); + + for rule in &self.rules { + if let Some(condition) = &rule.condition { + let condition_path = JsonPath::parse(&condition.source)?; + let condition_value = read_path(source, &condition_path); + + if condition_value != Some(&condition.equals) { + continue; + } + } + + let source_path = JsonPath::parse(&rule.source)?; + let target_path = JsonPath::parse(&rule.target)?; + + let Some(source_value) = read_path(source, &source_path) + .cloned() + .or(rule.default_value.clone()) + else { + if rule.required { + return Err(MappingError::MissingRequiredValue { + path: source_path.to_string_path(), + }); + } + + continue; + }; + + let value = apply_transform( + source_value, + rule.transform.as_ref().map(|value| &value.kind), + )?; + write_path(&mut target, &target_path, value)?; + } + + Ok(target) + } +} + +fn empty_target_context(context: MappingTargetContext) -> Value { + match context { + MappingTargetContext::Input => Value::Object(Map::from_iter([( + "request".to_owned(), + Value::Object(Map::from_iter([ + ("path".to_owned(), Value::Object(Map::new())), + ("query".to_owned(), Value::Object(Map::new())), + ("headers".to_owned(), Value::Object(Map::new())), + ("body".to_owned(), Value::Object(Map::new())), + ("variables".to_owned(), Value::Object(Map::new())), + ("grpc".to_owned(), Value::Object(Map::new())), + ])), + )])), + MappingTargetContext::Output => Value::Object(Map::from_iter([( + "output".to_owned(), + Value::Object(Map::new()), + )])), + _ => Value::Object(Map::new()), + } +} + +fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> { + let mut current = value; + + for field in path.root.root_fields() { + current = current.get(*field)?; + } + + for segment in &path.segments { + current = match segment { + JsonPathSegment::Field(field) => current.get(field)?, + JsonPathSegment::Index(index) => current.get(*index)?, + }; + } + + Some(current) +} + +fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> { + let mut segments = path + .root + .root_fields() + .iter() + .map(|field| JsonPathSegment::Field((*field).to_owned())) + .collect::>(); + segments.extend(path.segments.clone()); + + write_segments(target, &segments, value) +} + +fn write_segments( + target: &mut Value, + segments: &[JsonPathSegment], + value: Value, +) -> Result<(), MappingError> { + if segments.is_empty() { + *target = value; + return Ok(()); + } + + match &segments[0] { + JsonPathSegment::Field(field) => { + if !target.is_object() { + *target = Value::Object(Map::new()); + } + + let Some(object) = target.as_object_mut() else { + return Err(MappingError::InvalidJsonPath { + path: field.clone(), + }); + }; + let entry = object.entry(field.clone()).or_insert(Value::Null); + + write_segments(entry, &segments[1..], value) + } + JsonPathSegment::Index(index) => { + if !target.is_array() { + *target = Value::Array(Vec::new()); + } + + let Some(array) = target.as_array_mut() else { + return Err(MappingError::InvalidJsonPath { + path: index.to_string(), + }); + }; + while array.len() <= *index { + array.push(Value::Null); + } + + write_segments(&mut array[*index], &segments[1..], value) + } + } +} + +fn apply_transform(value: Value, transform: Option<&TransformKind>) -> Result { + let Some(transform) = transform else { + return Ok(value); + }; + + match transform { + TransformKind::Identity => Ok(value), + TransformKind::ToString => Ok(Value::String(to_string_value(&value))), + TransformKind::ToNumber => to_number(value), + TransformKind::ToBoolean => to_boolean(value), + TransformKind::Join => join_values(value), + TransformKind::Split => split_value(value), + TransformKind::WrapArray => Ok(match value { + Value::Array(values) => Value::Array(values), + other => Value::Array(vec![other]), + }), + TransformKind::UnwrapSingleton => unwrap_singleton(value), + } +} + +fn to_string_value(value: &Value) -> String { + match value { + Value::Null => "null".to_owned(), + Value::Bool(boolean) => boolean.to_string(), + Value::Number(number) => number.to_string(), + Value::String(text) => text.clone(), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +fn to_number(value: Value) -> Result { + let number = match value { + Value::Number(number) => return Ok(Value::Number(number)), + Value::String(text) => { + if let Ok(integer) = text.parse::() { + Number::from(integer) + } else { + Number::from_f64(text.parse::().map_err(|_| { + MappingError::InvalidTransformInput { + transform: "to_number", + actual: "string", + } + })?) + .ok_or(MappingError::InvalidTransformInput { + transform: "to_number", + actual: "string", + })? + } + } + Value::Bool(boolean) => Number::from(if boolean { 1 } else { 0 }), + other => { + return Err(MappingError::InvalidTransformInput { + transform: "to_number", + actual: type_name(&other), + }); + } + }; + + Ok(Value::Number(number)) +} + +fn to_boolean(value: Value) -> Result { + let boolean = match value { + Value::Bool(boolean) => boolean, + Value::Number(number) => { + number.as_i64().unwrap_or_default() != 0 || number.as_f64().unwrap_or_default() != 0.0 + } + Value::String(text) => match text.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + _ => { + return Err(MappingError::InvalidTransformInput { + transform: "to_boolean", + actual: "string", + }); + } + }, + other => { + return Err(MappingError::InvalidTransformInput { + transform: "to_boolean", + actual: type_name(&other), + }); + } + }; + + Ok(Value::Bool(boolean)) +} + +fn join_values(value: Value) -> Result { + let Value::Array(values) = value else { + return Err(MappingError::InvalidTransformInput { + transform: "join", + actual: type_name(&value), + }); + }; + + Ok(Value::String( + values + .iter() + .map(to_string_value) + .collect::>() + .join(","), + )) +} + +fn split_value(value: Value) -> Result { + let Value::String(text) = value else { + return Err(MappingError::InvalidTransformInput { + transform: "split", + actual: type_name(&value), + }); + }; + + Ok(Value::Array( + text.split(',') + .map(|part| Value::String(part.trim().to_owned())) + .collect(), + )) +} + +fn unwrap_singleton(value: Value) -> Result { + match value { + Value::Array(mut values) if values.len() == 1 => Ok(values.remove(0)), + Value::Array(_) => Err(MappingError::InvalidTransformInput { + transform: "unwrap_singleton", + actual: "array", + }), + other => Err(MappingError::InvalidTransformInput { + transform: "unwrap_singleton", + actual: type_name(&other), + }), + } +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::{MappingRule, MappingSet, Transform, TransformKind}; + + #[test] + fn applies_input_mapping_into_request_context() { + let mapping = MappingSet { + rules: vec![ + MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.contact.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }, + MappingRule { + source: "$.mcp.tags".to_owned(), + target: "$.request.query.tags".to_owned(), + required: false, + default_value: None, + transform: Some(Transform { + kind: TransformKind::Join, + }), + condition: None, + notes: None, + }, + ], + }; + + let result = mapping + .apply(&json!({ + "mcp": { + "email": "user@example.com", + "tags": ["warm", "priority"] + } + })) + .unwrap(); + + assert_eq!( + result["request"]["body"]["contact"]["email"], + "user@example.com" + ); + assert_eq!(result["request"]["query"]["tags"], "warm,priority"); + } + + #[test] + fn applies_output_mapping_into_output_context() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.response.body.lead_id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: Some(Transform { + kind: TransformKind::ToString, + }), + condition: None, + notes: None, + }], + }; + + let result = mapping + .apply(&json!({ + "response": { + "body": { + "lead_id": 42 + } + } + })) + .unwrap(); + + assert_eq!(result["output"]["id"], "42"); + } + + #[test] + fn uses_default_value_for_missing_optional_source() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.region".to_owned(), + target: "$.request.headers.X-Region".to_owned(), + required: false, + default_value: Some(json!("eu-central")), + transform: None, + condition: None, + notes: None, + }], + }; + + let result = mapping.apply(&json!({ "mcp": {} })).unwrap(); + + assert_eq!(result["request"]["headers"]["X-Region"], "eu-central"); + } +} diff --git a/crates/mcpaas-mapping/src/infer.rs b/crates/mcpaas-mapping/src/infer.rs new file mode 100644 index 0000000..557da5b --- /dev/null +++ b/crates/mcpaas-mapping/src/infer.rs @@ -0,0 +1,116 @@ +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::{JsonPathRoot, MappingRule, MappingSet}; + +pub fn infer_mapping_from_samples( + source_sample: &Value, + source_root: JsonPathRoot, + target_sample: &Value, + target_root: JsonPathRoot, +) -> MappingSet { + let source_paths = collect_leaf_paths(source_sample, source_root); + let target_paths = collect_leaf_paths(target_sample, target_root); + let mut rules = Vec::new(); + + for (leaf_name, source_path) in source_paths { + let Some(target_path) = target_paths.get(&leaf_name) else { + continue; + }; + + rules.push(MappingRule { + source: source_path, + target: target_path.clone(), + required: false, + default_value: None, + transform: None, + condition: None, + notes: None, + }); + } + + MappingSet { rules } +} + +fn collect_leaf_paths(sample: &Value, root: JsonPathRoot) -> BTreeMap { + let mut collected = BTreeMap::new(); + let mut segments = Vec::new(); + + collect_leaf_paths_recursive(sample, &mut segments, &mut collected); + + collected + .into_iter() + .map(|(leaf, path_segments)| { + let mut path = format!("$.{}", root.as_str()); + + for segment in path_segments { + path.push('.'); + path.push_str(&segment); + } + + (leaf, path) + }) + .collect() +} + +fn collect_leaf_paths_recursive( + sample: &Value, + segments: &mut Vec, + collected: &mut BTreeMap>, +) { + match sample { + Value::Object(object) => { + for (field, value) in object { + segments.push(field.clone()); + collect_leaf_paths_recursive(value, segments, collected); + segments.pop(); + } + } + Value::Array(values) => { + if let Some(first) = values.first() { + collect_leaf_paths_recursive(first, segments, collected); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + if let Some(leaf) = segments.last() { + collected + .entry(leaf.clone()) + .or_insert_with(|| segments.clone()); + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::{JsonPathRoot, infer_mapping_from_samples}; + + #[test] + fn infers_rules_from_matching_leaf_names() { + let mapping = infer_mapping_from_samples( + &json!({ + "lead": { + "email": "user@example.com", + "name": "Ada" + } + }), + JsonPathRoot::Mcp, + &json!({ + "contact": { + "email": "user@example.com" + }, + "name": "Ada" + }), + JsonPathRoot::RequestBody, + ); + + assert_eq!(mapping.rules.len(), 2); + assert_eq!(mapping.rules[0].source, "$.mcp.lead.email"); + assert_eq!(mapping.rules[0].target, "$.request.body.contact.email"); + assert_eq!(mapping.rules[1].source, "$.mcp.lead.name"); + assert_eq!(mapping.rules[1].target, "$.request.body.name"); + } +} diff --git a/crates/mcpaas-mapping/src/jsonpath.rs b/crates/mcpaas-mapping/src/jsonpath.rs new file mode 100644 index 0000000..91a40f4 --- /dev/null +++ b/crates/mcpaas-mapping/src/jsonpath.rs @@ -0,0 +1,214 @@ +use crate::MappingError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JsonPathRoot { + Mcp, + RequestPath, + RequestQuery, + RequestHeaders, + RequestBody, + RequestVariables, + RequestGrpc, + ResponseBody, + ResponseData, + ResponseGrpc, + Output, +} + +impl JsonPathRoot { + pub fn as_str(&self) -> &'static str { + match self { + Self::Mcp => "mcp", + Self::RequestPath => "request.path", + Self::RequestQuery => "request.query", + Self::RequestHeaders => "request.headers", + Self::RequestBody => "request.body", + Self::RequestVariables => "request.variables", + Self::RequestGrpc => "request.grpc", + Self::ResponseBody => "response.body", + Self::ResponseData => "response.data", + Self::ResponseGrpc => "response.grpc", + Self::Output => "output", + } + } + + pub fn root_fields(&self) -> &'static [&'static str] { + match self { + Self::Mcp => &["mcp"], + Self::RequestPath => &["request", "path"], + Self::RequestQuery => &["request", "query"], + Self::RequestHeaders => &["request", "headers"], + Self::RequestBody => &["request", "body"], + Self::RequestVariables => &["request", "variables"], + Self::RequestGrpc => &["request", "grpc"], + Self::ResponseBody => &["response", "body"], + Self::ResponseData => &["response", "data"], + Self::ResponseGrpc => &["response", "grpc"], + Self::Output => &["output"], + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum JsonPathSegment { + Field(String), + Index(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JsonPath { + pub root: JsonPathRoot, + pub segments: Vec, +} + +impl JsonPath { + pub fn parse(path: &str) -> Result { + let body = path + .strip_prefix("$.") + .ok_or_else(|| MappingError::InvalidJsonPath { + path: path.to_owned(), + })?; + + let (root, rest) = match_root(body).ok_or_else(|| MappingError::InvalidJsonPath { + path: path.to_owned(), + })?; + + if !(rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) { + return Err(MappingError::InvalidJsonPath { + path: path.to_owned(), + }); + } + + let segments = parse_segments(rest, path)?; + + Ok(Self { root, segments }) + } + + pub fn to_string_path(&self) -> String { + let mut path = format!("$.{}", self.root.as_str()); + + for segment in &self.segments { + match segment { + JsonPathSegment::Field(field) => { + path.push('.'); + path.push_str(field); + } + JsonPathSegment::Index(index) => { + path.push('['); + path.push_str(&index.to_string()); + path.push(']'); + } + } + } + + path + } +} + +fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> { + const ROOTS: [(&str, JsonPathRoot); 11] = [ + ("request.variables", JsonPathRoot::RequestVariables), + ("request.headers", JsonPathRoot::RequestHeaders), + ("response.body", JsonPathRoot::ResponseBody), + ("response.data", JsonPathRoot::ResponseData), + ("response.grpc", JsonPathRoot::ResponseGrpc), + ("request.path", JsonPathRoot::RequestPath), + ("request.query", JsonPathRoot::RequestQuery), + ("request.body", JsonPathRoot::RequestBody), + ("request.grpc", JsonPathRoot::RequestGrpc), + ("output", JsonPathRoot::Output), + ("mcp", JsonPathRoot::Mcp), + ]; + + for (prefix, root) in ROOTS { + if let Some(rest) = input.strip_prefix(prefix) { + return Some((root, rest)); + } + } + + None +} + +fn parse_segments(mut input: &str, original: &str) -> Result, MappingError> { + let mut segments = Vec::new(); + + while !input.is_empty() { + if let Some(rest) = input.strip_prefix('.') { + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let field = &rest[..end]; + + if field.is_empty() || !is_valid_field(field) { + return Err(MappingError::InvalidJsonPath { + path: original.to_owned(), + }); + } + + segments.push(JsonPathSegment::Field(field.to_owned())); + input = &rest[end..]; + continue; + } + + if let Some(rest) = input.strip_prefix('[') { + let Some(end) = rest.find(']') else { + return Err(MappingError::InvalidJsonPath { + path: original.to_owned(), + }); + }; + + let index = + rest[..end] + .parse::() + .map_err(|_| MappingError::InvalidJsonPath { + path: original.to_owned(), + })?; + + segments.push(JsonPathSegment::Index(index)); + input = &rest[end + 1..]; + continue; + } + + return Err(MappingError::InvalidJsonPath { + path: original.to_owned(), + }); + } + + Ok(segments) +} + +fn is_valid_field(value: &str) -> bool { + value + .chars() + .all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-') +} + +#[cfg(test)] +mod tests { + use super::{JsonPath, JsonPathRoot, JsonPathSegment}; + + #[test] + fn parses_nested_jsonpath_with_array_index() { + let path = JsonPath::parse("$.request.body.contacts[0].email").unwrap(); + + assert_eq!(path.root, JsonPathRoot::RequestBody); + assert_eq!( + path.segments, + vec![ + JsonPathSegment::Field("contacts".to_owned()), + JsonPathSegment::Index(0), + JsonPathSegment::Field("email".to_owned()) + ] + ); + } + + #[test] + fn rejects_unknown_root() { + let error = JsonPath::parse("$.unknown.field").unwrap_err(); + + assert_eq!( + error, + crate::MappingError::InvalidJsonPath { + path: "$.unknown.field".to_owned(), + } + ); + } +} diff --git a/crates/mcpaas-mapping/src/lib.rs b/crates/mcpaas-mapping/src/lib.rs index 893031e..5646e1e 100644 --- a/crates/mcpaas-mapping/src/lib.rs +++ b/crates/mcpaas-mapping/src/lib.rs @@ -1,129 +1,12 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; +mod error; +mod execute; +mod infer; +mod jsonpath; +mod model; -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MappingCondition { - pub source: String, - pub equals: Value, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TransformKind { - Identity, - ToString, - ToNumber, - ToBoolean, - Join, - Split, - WrapArray, - UnwrapSingleton, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Transform { - pub kind: TransformKind, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MappingRule { - pub source: String, - pub target: String, - #[serde(default)] - pub required: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_value: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transform: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub condition: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -pub struct MappingSet { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub rules: Vec, -} - -impl MappingSet { - pub fn is_empty(&self) -> bool { - self.rules.is_empty() - } - - pub fn len(&self) -> usize { - self.rules.len() - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::{MappingRule, MappingSet, Transform, TransformKind}; - - #[test] - fn mapping_set_reports_non_empty_rules() { - let mapping = MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.body.contact.email".to_owned(), - required: true, - default_value: None, - transform: Some(Transform { - kind: TransformKind::Identity, - }), - condition: None, - notes: None, - }], - }; - - assert!(!mapping.is_empty()); - assert_eq!(mapping.len(), 1); - } - - #[test] - fn mapping_rule_serializes_jsonpath_fields() { - let mapping = MappingSet { - rules: vec![MappingRule { - source: "$.response.body.id".to_owned(), - target: "$.output.id".to_owned(), - required: false, - default_value: Some(json!("lead_123")), - transform: None, - condition: None, - notes: Some("map identifier".to_owned()), - }], - }; - - let value = serde_json::to_value(mapping).unwrap(); - - assert_eq!(value["rules"][0]["source"], "$.response.body.id"); - assert_eq!(value["rules"][0]["target"], "$.output.id"); - assert_eq!(value["rules"][0]["default_value"], "lead_123"); - } - - #[test] - fn mapping_set_roundtrips_through_yaml() { - let mapping = MappingSet { - rules: vec![MappingRule { - source: "$.mcp.tags".to_owned(), - target: "$.request.body.tags".to_owned(), - required: false, - default_value: None, - transform: Some(Transform { - kind: TransformKind::WrapArray, - }), - condition: None, - notes: Some("normalize tags".to_owned()), - }], - }; - - let yaml = serde_yaml::to_string(&mapping).unwrap(); - let restored: MappingSet = serde_yaml::from_str(&yaml).unwrap(); - - assert!(yaml.contains("source: $.mcp.tags")); - assert_eq!(restored, mapping); - } -} +pub use error::MappingError; +pub use infer::infer_mapping_from_samples; +pub use jsonpath::{JsonPath, JsonPathRoot, JsonPathSegment}; +pub use model::{ + MappingCondition, MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind, +}; diff --git a/crates/mcpaas-mapping/src/model.rs b/crates/mcpaas-mapping/src/model.rs new file mode 100644 index 0000000..ca73a96 --- /dev/null +++ b/crates/mcpaas-mapping/src/model.rs @@ -0,0 +1,311 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{JsonPath, JsonPathRoot, MappingError}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MappingTargetContext { + Unknown, + Input, + Output, + Mixed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MappingCondition { + pub source: String, + pub equals: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransformKind { + Identity, + ToString, + ToNumber, + ToBoolean, + Join, + Split, + WrapArray, + UnwrapSingleton, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Transform { + pub kind: TransformKind, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MappingRule { + pub source: String, + pub target: String, + #[serde(default)] + pub required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transform: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct MappingSet { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +impl MappingSet { + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + pub fn len(&self) -> usize { + self.rules.len() + } + + pub fn target_context(&self) -> MappingTargetContext { + let mut detected = MappingTargetContext::Unknown; + + for rule in &self.rules { + let Ok(path) = JsonPath::parse(&rule.target) else { + return MappingTargetContext::Mixed; + }; + + let current = match target_root_context(path.root) { + Some(context) => context, + None => return MappingTargetContext::Mixed, + }; + + detected = match (detected, current) { + (MappingTargetContext::Unknown, context) => context, + (existing, current) if existing == current => existing, + _ => MappingTargetContext::Mixed, + }; + } + + detected + } + + pub fn validate_paths(&self) -> Result<(), MappingError> { + let context = self.target_context(); + + if context == MappingTargetContext::Mixed { + return Err(MappingError::MixedTargetContext); + } + + for rule in &self.rules { + let source = JsonPath::parse(&rule.source)?; + let target = JsonPath::parse(&rule.target)?; + + validate_source_root(source.root, context)?; + validate_target_root(target.root, context)?; + + if let Some(condition) = &rule.condition { + let condition_path = JsonPath::parse(&condition.source)?; + validate_source_root(condition_path.root, context)?; + } + } + + Ok(()) + } +} + +fn target_root_context(root: JsonPathRoot) -> Option { + match root { + JsonPathRoot::RequestPath + | JsonPathRoot::RequestQuery + | JsonPathRoot::RequestHeaders + | JsonPathRoot::RequestBody + | JsonPathRoot::RequestVariables + | JsonPathRoot::RequestGrpc => Some(MappingTargetContext::Input), + JsonPathRoot::Output => Some(MappingTargetContext::Output), + _ => None, + } +} + +fn validate_source_root( + root: JsonPathRoot, + context: MappingTargetContext, +) -> Result<(), MappingError> { + let valid = match context { + MappingTargetContext::Unknown => true, + MappingTargetContext::Input => root == JsonPathRoot::Mcp, + MappingTargetContext::Output => { + matches!( + root, + JsonPathRoot::ResponseBody + | JsonPathRoot::ResponseData + | JsonPathRoot::ResponseGrpc + ) + } + MappingTargetContext::Mixed => false, + }; + + if valid { + return Ok(()); + } + + Err(MappingError::UnsupportedSourceRoot { + root: root.as_str().to_owned(), + context: context_name(context), + }) +} + +fn validate_target_root( + root: JsonPathRoot, + context: MappingTargetContext, +) -> Result<(), MappingError> { + let valid = match context { + MappingTargetContext::Unknown => true, + MappingTargetContext::Input => matches!( + root, + JsonPathRoot::RequestPath + | JsonPathRoot::RequestQuery + | JsonPathRoot::RequestHeaders + | JsonPathRoot::RequestBody + | JsonPathRoot::RequestVariables + | JsonPathRoot::RequestGrpc + ), + MappingTargetContext::Output => root == JsonPathRoot::Output, + MappingTargetContext::Mixed => false, + }; + + if valid { + return Ok(()); + } + + Err(MappingError::UnsupportedTargetRoot { + root: root.as_str().to_owned(), + context: context_name(context), + }) +} + +fn context_name(context: MappingTargetContext) -> &'static str { + match context { + MappingTargetContext::Unknown => "unknown", + MappingTargetContext::Input => "input", + MappingTargetContext::Output => "output", + MappingTargetContext::Mixed => "mixed", + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind}; + + #[test] + fn mapping_set_reports_non_empty_rules() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.contact.email".to_owned(), + required: true, + default_value: None, + transform: Some(Transform { + kind: TransformKind::Identity, + }), + condition: None, + notes: None, + }], + }; + + assert!(!mapping.is_empty()); + assert_eq!(mapping.len(), 1); + } + + #[test] + fn mapping_rule_serializes_jsonpath_fields() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: false, + default_value: Some(json!("lead_123")), + transform: None, + condition: None, + notes: Some("map identifier".to_owned()), + }], + }; + + let value = serde_json::to_value(mapping).unwrap(); + + assert_eq!(value["rules"][0]["source"], "$.response.body.id"); + assert_eq!(value["rules"][0]["target"], "$.output.id"); + assert_eq!(value["rules"][0]["default_value"], "lead_123"); + } + + #[test] + fn mapping_set_roundtrips_through_yaml() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.tags".to_owned(), + target: "$.request.body.tags".to_owned(), + required: false, + default_value: None, + transform: Some(Transform { + kind: TransformKind::WrapArray, + }), + condition: None, + notes: Some("normalize tags".to_owned()), + }], + }; + + let yaml = serde_yaml::to_string(&mapping).unwrap(); + let restored: MappingSet = serde_yaml::from_str(&yaml).unwrap(); + + assert!(yaml.contains("source: $.mcp.tags")); + assert_eq!(restored, mapping); + } + + #[test] + fn detects_input_context_from_target_roots() { + let mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }; + + assert_eq!(mapping.target_context(), MappingTargetContext::Input); + } + + #[test] + fn rejects_mixed_target_contexts() { + let mapping = MappingSet { + rules: vec![ + MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }, + MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }, + ], + }; + + let error = mapping.validate_paths().unwrap_err(); + + assert_eq!(error, crate::MappingError::MixedTargetContext); + } +} diff --git a/crates/mcpaas-registry/Cargo.toml b/crates/mcpaas-registry/Cargo.toml index 47eccbe..a65873c 100644 --- a/crates/mcpaas-registry/Cargo.toml +++ b/crates/mcpaas-registry/Cargo.toml @@ -11,5 +11,8 @@ mcpaas-mapping = { path = "../mcpaas-mapping" } mcpaas-schema = { path = "../mcpaas-schema" } serde.workspace = true serde_json.workspace = true +sqlx.workspace = true thiserror.workspace = true +[dev-dependencies] +tokio.workspace = true diff --git a/crates/mcpaas-registry/src/error.rs b/crates/mcpaas-registry/src/error.rs new file mode 100644 index 0000000..1837ca4 --- /dev/null +++ b/crates/mcpaas-registry/src/error.rs @@ -0,0 +1,36 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error(transparent)] + Storage(#[from] sqlx::Error), + #[error(transparent)] + Serialization(#[from] serde_json::Error), + #[error("operation {operation_id} already exists")] + OperationAlreadyExists { operation_id: String }, + #[error("operation {operation_id} was not found")] + OperationNotFound { operation_id: String }, + #[error("operation version {version} for {operation_id} was not found")] + OperationVersionNotFound { operation_id: String, version: u32 }, + #[error("operation {operation_id} must start with version 1, got {version}")] + InvalidInitialVersion { operation_id: String, version: u32 }, + #[error("operation {operation_id} expected next version {expected}, got {actual}")] + InvalidVersionSequence { + operation_id: String, + expected: u32, + actual: u32, + }, + #[error("operation {operation_id} changed immutable field {field}")] + ImmutableOperationFieldChanged { + operation_id: String, + field: &'static str, + }, + #[error("auth profile {auth_profile_id} was not found")] + AuthProfileNotFound { auth_profile_id: String }, + #[error("yaml import job {job_id} was not found")] + YamlImportJobNotFound { job_id: String }, + #[error("unsupported enum representation for field {field}")] + InvalidEnumRepresentation { field: &'static str }, + #[error("invalid numeric value for field {field}: {value}")] + InvalidNumericValue { field: &'static str, value: i64 }, +} diff --git a/crates/mcpaas-registry/src/lib.rs b/crates/mcpaas-registry/src/lib.rs index dee68c0..ed2c39c 100644 --- a/crates/mcpaas-registry/src/lib.rs +++ b/crates/mcpaas-registry/src/lib.rs @@ -1,3 +1,14 @@ -pub fn crate_name() -> &'static str { - "mcpaas-registry" -} +mod error; +mod migrations; +mod model; +mod postgres; + +pub use error::RegistryError; +pub use model::{ + CreateVersionRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, + OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishRequest, + RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, + YamlImportJobStatus, +}; +pub use postgres::PostgresRegistry; diff --git a/crates/mcpaas-registry/src/migrations.rs b/crates/mcpaas-registry/src/migrations.rs new file mode 100644 index 0000000..101712f --- /dev/null +++ b/crates/mcpaas-registry/src/migrations.rs @@ -0,0 +1,120 @@ +use sqlx::{PgPool, query}; + +pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { + query( + "create table if not exists operations ( + id text primary key, + name text not null unique, + display_name text not null, + protocol text not null, + status text not null, + current_draft_version integer not null default 1, + latest_published_version integer null, + created_at timestamptz not null, + updated_at timestamptz not null, + published_at timestamptz null + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists operation_versions ( + operation_id text not null references operations(id) on delete cascade, + version integer not null, + status text not null, + target_json jsonb not null, + input_schema_json jsonb not null, + output_schema_json jsonb not null, + input_mapping_json jsonb not null, + output_mapping_json jsonb not null, + execution_config_json jsonb not null, + tool_description_json jsonb not null, + samples_json jsonb null, + generated_draft_json jsonb null, + config_export_json jsonb null, + change_note text null, + created_at timestamptz not null, + created_by text null, + primary key (operation_id, version) + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists published_operations ( + operation_id text primary key references operations(id) on delete cascade, + version integer not null, + published_at timestamptz not null, + published_by text null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists operation_samples ( + id text primary key, + operation_id text not null references operations(id) on delete cascade, + version integer not null, + sample_kind text not null, + storage_ref text not null, + content_type text not null, + file_name text null, + created_at timestamptz not null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists descriptors ( + id text primary key, + operation_id text null references operations(id) on delete cascade, + version integer null, + descriptor_kind text not null, + storage_ref text not null, + source_name text null, + package_index_json jsonb null, + created_at timestamptz not null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists auth_profiles ( + id text primary key, + name text not null unique, + kind text not null, + config_json jsonb not null, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists yaml_import_jobs ( + id text primary key, + source_sample_id text null references operation_samples(id) on delete set null, + status text not null, + format_version text not null, + mode text not null, + result_operation_id text null references operations(id) on delete set null, + result_version integer null, + error_text text null, + created_at timestamptz not null, + finished_at timestamptz null + )", + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/crates/mcpaas-registry/src/model.rs b/crates/mcpaas-registry/src/model.rs new file mode 100644 index 0000000..a827937 --- /dev/null +++ b/crates/mcpaas-registry/src/model.rs @@ -0,0 +1,176 @@ +use mcpaas_core::{ + AuthProfile, DescriptorId, ExportMode, Operation, OperationId, OperationStatus, Protocol, + SampleId, +}; +use mcpaas_mapping::MappingSet; +use mcpaas_schema::Schema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +macro_rules! define_registry_id { + ($name:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + }; +} + +define_registry_id!(YamlImportJobId); + +pub type RegistryOperation = Operation; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationSummary { + pub id: OperationId, + pub name: String, + pub display_name: String, + pub protocol: Protocol, + pub status: OperationStatus, + pub current_draft_version: u32, + pub latest_published_version: Option, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OperationVersionRecord { + pub operation_id: OperationId, + pub version: u32, + pub status: OperationStatus, + pub change_note: Option, + pub created_at: String, + pub created_by: Option, + pub snapshot: RegistryOperation, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SampleKind { + InputJson, + OutputJson, + YamlImportSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationSampleMetadata { + pub id: SampleId, + pub operation_id: OperationId, + pub version: u32, + pub sample_kind: SampleKind, + pub storage_ref: String, + pub content_type: String, + pub file_name: Option, + pub created_at: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DescriptorKind { + ProtoUpload, + DescriptorSet, + ReflectionSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DescriptorMetadata { + pub id: DescriptorId, + pub operation_id: Option, + pub version: Option, + pub descriptor_kind: DescriptorKind, + pub storage_ref: String, + pub source_name: Option, + pub package_index: Option, + pub created_at: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum YamlImportJobStatus { + Pending, + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct YamlImportJob { + pub id: YamlImportJobId, + pub source_sample_id: Option, + pub status: YamlImportJobStatus, + pub format_version: String, + pub mode: ExportMode, + pub result_operation_id: Option, + pub result_version: Option, + pub error_text: Option, + pub created_at: String, + pub finished_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct YamlImportJobCompletion { + pub status: YamlImportJobStatus, + pub result_operation_id: Option, + pub result_version: Option, + pub error_text: Option, + pub finished_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CreateVersionRequest<'a> { + pub snapshot: &'a RegistryOperation, + pub change_note: Option<&'a str>, + pub created_by: Option<&'a str>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishRequest<'a> { + pub operation_id: &'a OperationId, + pub version: u32, + pub published_at: &'a str, + pub published_by: Option<&'a str>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateYamlImportJobRequest<'a> { + pub id: &'a YamlImportJobId, + pub source_sample_id: Option<&'a SampleId>, + pub format_version: &'a str, + pub mode: ExportMode, + pub created_at: &'a str, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SaveAuthProfileRequest<'a> { + pub profile: &'a AuthProfile, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SaveSampleMetadataRequest<'a> { + pub sample: &'a OperationSampleMetadata, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SaveDescriptorMetadataRequest<'a> { + pub descriptor: &'a DescriptorMetadata, +} diff --git a/crates/mcpaas-registry/src/postgres.rs b/crates/mcpaas-registry/src/postgres.rs new file mode 100644 index 0000000..3bd71bf --- /dev/null +++ b/crates/mcpaas-registry/src/postgres.rs @@ -0,0 +1,1455 @@ +use mcpaas_core::{AuthProfile, OperationId, OperationStatus}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use sqlx::{ + PgPool, Postgres, Row, Transaction, + postgres::{PgPoolOptions, PgRow}, + types::Json, +}; + +use crate::{ + error::RegistryError, + migrations, + model::{ + CreateVersionRequest, CreateYamlImportJobRequest, DescriptorMetadata, + OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishRequest, + RegistryOperation, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, + YamlImportJobStatus, + }, +}; + +#[derive(Clone, Debug)] +pub struct PostgresRegistry { + pool: PgPool, +} + +impl PostgresRegistry { + pub async fn connect(database_url: &str) -> Result { + Self::connect_in_schema(database_url, None).await + } + + pub fn pool(&self) -> &PgPool { + &self.pool + } + + pub async fn migrate(&self) -> Result<(), RegistryError> { + migrations::apply_postgres(&self.pool).await?; + Ok(()) + } + + async fn connect_in_schema( + database_url: &str, + schema: Option<&str>, + ) -> Result { + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(database_url) + .await?; + + if let Some(schema) = schema { + sqlx::query(&format!("set search_path to {schema}")) + .execute(&pool) + .await?; + } + + let registry = Self { pool }; + registry.migrate().await?; + Ok(registry) + } + + pub async fn create_operation( + &self, + snapshot: &RegistryOperation, + created_by: Option<&str>, + ) -> Result<(), RegistryError> { + if snapshot.version != 1 { + return Err(RegistryError::InvalidInitialVersion { + operation_id: snapshot.id.as_str().to_owned(), + version: snapshot.version, + }); + } + + if self.get_operation_summary(&snapshot.id).await?.is_some() { + return Err(RegistryError::OperationAlreadyExists { + operation_id: snapshot.id.as_str().to_owned(), + }); + } + + let mut tx = self.pool.begin().await?; + + sqlx::query( + "insert into operations ( + id, + name, + display_name, + protocol, + status, + current_draft_version, + latest_published_version, + created_at, + updated_at, + published_at + ) values ( + $1, $2, $3, $4, $5, $6, $7, + $8::timestamptz, + $9::timestamptz, + $10::timestamptz + )", + ) + .bind(snapshot.id.as_str()) + .bind(&snapshot.name) + .bind(&snapshot.display_name) + .bind(serialize_enum_text(&snapshot.protocol, "protocol")?) + .bind(serialize_enum_text(&snapshot.status, "status")?) + .bind(to_db_version(snapshot.version)) + .bind( + snapshot + .published_at + .as_ref() + .map(|_| to_db_version(snapshot.version)), + ) + .bind(&snapshot.created_at) + .bind(&snapshot.updated_at) + .bind(snapshot.published_at.as_deref()) + .execute(&mut *tx) + .await?; + + insert_version_row(&mut tx, snapshot, None, created_by).await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn create_version( + &self, + request: CreateVersionRequest<'_>, + ) -> Result<(), RegistryError> { + let Some(summary) = self.get_operation_summary(&request.snapshot.id).await? else { + return Err(RegistryError::OperationNotFound { + operation_id: request.snapshot.id.as_str().to_owned(), + }); + }; + + assert_immutable_fields(&summary, request.snapshot)?; + + let expected = summary.current_draft_version + 1; + if request.snapshot.version != expected { + return Err(RegistryError::InvalidVersionSequence { + operation_id: request.snapshot.id.as_str().to_owned(), + expected, + actual: request.snapshot.version, + }); + } + + let mut tx = self.pool.begin().await?; + + insert_version_row( + &mut tx, + request.snapshot, + request.change_note, + request.created_by, + ) + .await?; + + sqlx::query( + "update operations + set status = $1, + current_draft_version = $2, + updated_at = $3::timestamptz + where id = $4", + ) + .bind(serialize_enum_text(&request.snapshot.status, "status")?) + .bind(to_db_version(request.snapshot.version)) + .bind(&request.snapshot.updated_at) + .bind(request.snapshot.id.as_str()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn list_operations(&self) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + name, + display_name, + protocol, + status, + current_draft_version, + latest_published_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at + from operations + order by name asc", + ) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_operation_summary).collect() + } + + pub async fn get_operation_summary( + &self, + operation_id: &OperationId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + name, + display_name, + protocol, + status, + current_draft_version, + latest_published_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at + from operations + where id = $1", + ) + .bind(operation_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_operation_summary).transpose() + } + + pub async fn get_operation_version( + &self, + operation_id: &OperationId, + version: u32, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + o.id, + o.name, + o.display_name, + o.protocol, + to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, + to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, + to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at, + ov.version, + ov.status, + ov.target_json, + ov.input_schema_json, + ov.output_schema_json, + ov.input_mapping_json, + ov.output_mapping_json, + ov.execution_config_json, + ov.tool_description_json, + ov.samples_json, + ov.generated_draft_json, + ov.config_export_json, + ov.change_note, + to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + ov.created_by + from operation_versions ov + join operations o on o.id = ov.operation_id + where ov.operation_id = $1 and ov.version = $2", + ) + .bind(operation_id.as_str()) + .bind(to_db_version(version)) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_operation_version_record).transpose() + } + + pub async fn list_operation_versions( + &self, + operation_id: &OperationId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + o.id, + o.name, + o.display_name, + o.protocol, + to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, + to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, + to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at, + ov.version, + ov.status, + ov.target_json, + ov.input_schema_json, + ov.output_schema_json, + ov.input_mapping_json, + ov.output_mapping_json, + ov.execution_config_json, + ov.tool_description_json, + ov.samples_json, + ov.generated_draft_json, + ov.config_export_json, + ov.change_note, + to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + ov.created_by + from operation_versions ov + join operations o on o.id = ov.operation_id + where ov.operation_id = $1 + order by ov.version asc", + ) + .bind(operation_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_operation_version_record).collect() + } + + pub async fn publish_operation( + &self, + request: PublishRequest<'_>, + ) -> Result<(), RegistryError> { + if self + .get_operation_version(request.operation_id, request.version) + .await? + .is_none() + { + return Err(RegistryError::OperationVersionNotFound { + operation_id: request.operation_id.as_str().to_owned(), + version: request.version, + }); + } + + let mut tx = self.pool.begin().await?; + + sqlx::query( + "insert into published_operations ( + operation_id, + version, + published_at, + published_by + ) values ($1, $2, $3::timestamptz, $4) + on conflict(operation_id) do update set + version = excluded.version, + published_at = excluded.published_at, + published_by = excluded.published_by", + ) + .bind(request.operation_id.as_str()) + .bind(to_db_version(request.version)) + .bind(request.published_at) + .bind(request.published_by) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update operation_versions + set status = $1 + where operation_id = $2 and version = $3", + ) + .bind(serialize_enum_text(&OperationStatus::Published, "status")?) + .bind(request.operation_id.as_str()) + .bind(to_db_version(request.version)) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update operations + set status = $1, + latest_published_version = $2, + published_at = $3::timestamptz, + updated_at = $4::timestamptz + where id = $5", + ) + .bind(serialize_enum_text(&OperationStatus::Published, "status")?) + .bind(to_db_version(request.version)) + .bind(request.published_at) + .bind(request.published_at) + .bind(request.operation_id.as_str()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn get_published_operation( + &self, + operation_id: &OperationId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + o.id, + o.name, + o.display_name, + o.protocol, + to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, + to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, + to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at, + ov.version, + ov.status, + ov.target_json, + ov.input_schema_json, + ov.output_schema_json, + ov.input_mapping_json, + ov.output_mapping_json, + ov.execution_config_json, + ov.tool_description_json, + ov.samples_json, + ov.generated_draft_json, + ov.config_export_json, + ov.change_note, + to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + ov.created_by + from published_operations po + join operation_versions ov + on ov.operation_id = po.operation_id and ov.version = po.version + join operations o on o.id = po.operation_id + where po.operation_id = $1", + ) + .bind(operation_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref() + .map(|value| map_operation_version_record(value).map(|record| record.snapshot)) + .transpose() + } + + pub async fn list_published_operations(&self) -> Result, RegistryError> { + let rows = sqlx::query( + "select + o.id, + o.name, + o.display_name, + o.protocol, + to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, + to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, + to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at, + ov.version, + ov.status, + ov.target_json, + ov.input_schema_json, + ov.output_schema_json, + ov.input_mapping_json, + ov.output_mapping_json, + ov.execution_config_json, + ov.tool_description_json, + ov.samples_json, + ov.generated_draft_json, + ov.config_export_json, + ov.change_note, + to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + ov.created_by + from published_operations po + join operation_versions ov + on ov.operation_id = po.operation_id and ov.version = po.version + join operations o on o.id = po.operation_id + order by o.name asc", + ) + .fetch_all(&self.pool) + .await?; + + rows.iter() + .map(|row| map_operation_version_record(row).map(|record| record.snapshot)) + .collect() + } + + pub async fn save_auth_profile( + &self, + request: SaveAuthProfileRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into auth_profiles ( + id, + name, + kind, + config_json, + created_at, + updated_at + ) values ($1, $2, $3, $4, $5::timestamptz, $6::timestamptz) + on conflict(id) do update set + name = excluded.name, + kind = excluded.kind, + config_json = excluded.config_json, + updated_at = excluded.updated_at", + ) + .bind(request.profile.id.as_str()) + .bind(&request.profile.name) + .bind(serialize_enum_text(&request.profile.kind, "kind")?) + .bind(Json(serialize_json_value(&request.profile.config)?)) + .bind(&request.profile.created_at) + .bind(&request.profile.updated_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_auth_profile( + &self, + auth_profile_id: &mcpaas_core::AuthProfileId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + name, + kind, + config_json, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at + from auth_profiles + where id = $1", + ) + .bind(auth_profile_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_auth_profile).transpose() + } + + pub async fn list_auth_profiles(&self) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + name, + kind, + config_json, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at + from auth_profiles + order by name asc", + ) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_auth_profile).collect() + } + + pub async fn save_sample_metadata( + &self, + request: SaveSampleMetadataRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into operation_samples ( + id, + operation_id, + version, + sample_kind, + storage_ref, + content_type, + file_name, + created_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz) + on conflict(id) do update set + operation_id = excluded.operation_id, + version = excluded.version, + sample_kind = excluded.sample_kind, + storage_ref = excluded.storage_ref, + content_type = excluded.content_type, + file_name = excluded.file_name, + created_at = excluded.created_at", + ) + .bind(request.sample.id.as_str()) + .bind(request.sample.operation_id.as_str()) + .bind(to_db_version(request.sample.version)) + .bind(serialize_enum_text( + &request.sample.sample_kind, + "sample_kind", + )?) + .bind(&request.sample.storage_ref) + .bind(&request.sample.content_type) + .bind(request.sample.file_name.as_deref()) + .bind(&request.sample.created_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn list_sample_metadata( + &self, + operation_id: &OperationId, + version: u32, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + operation_id, + version, + sample_kind, + storage_ref, + content_type, + file_name, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at + from operation_samples + where operation_id = $1 and version = $2 + order by created_at asc", + ) + .bind(operation_id.as_str()) + .bind(to_db_version(version)) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_sample_metadata).collect() + } + + pub async fn save_descriptor_metadata( + &self, + request: SaveDescriptorMetadataRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into descriptors ( + id, + operation_id, + version, + descriptor_kind, + storage_ref, + source_name, + package_index_json, + created_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz) + on conflict(id) do update set + operation_id = excluded.operation_id, + version = excluded.version, + descriptor_kind = excluded.descriptor_kind, + storage_ref = excluded.storage_ref, + source_name = excluded.source_name, + package_index_json = excluded.package_index_json, + created_at = excluded.created_at", + ) + .bind(request.descriptor.id.as_str()) + .bind( + request + .descriptor + .operation_id + .as_ref() + .map(|value| value.as_str()), + ) + .bind(request.descriptor.version.map(to_db_version)) + .bind(serialize_enum_text( + &request.descriptor.descriptor_kind, + "descriptor_kind", + )?) + .bind(&request.descriptor.storage_ref) + .bind(request.descriptor.source_name.as_deref()) + .bind(serialize_option_json_value(&request.descriptor.package_index)?.map(Json)) + .bind(&request.descriptor.created_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn list_descriptor_metadata( + &self, + operation_id: &OperationId, + version: u32, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + operation_id, + version, + descriptor_kind, + storage_ref, + source_name, + package_index_json, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at + from descriptors + where operation_id = $1 and version = $2 + order by created_at asc", + ) + .bind(operation_id.as_str()) + .bind(to_db_version(version)) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_descriptor_metadata).collect() + } + + pub async fn create_yaml_import_job( + &self, + request: CreateYamlImportJobRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into yaml_import_jobs ( + id, + source_sample_id, + status, + format_version, + mode, + result_operation_id, + result_version, + error_text, + created_at, + finished_at + ) values ($1, $2, $3, $4, $5, null, null, null, $6::timestamptz, null)", + ) + .bind(request.id.as_str()) + .bind(request.source_sample_id.map(|value| value.as_str())) + .bind(serialize_enum_text( + &YamlImportJobStatus::Pending, + "status", + )?) + .bind(request.format_version) + .bind(serialize_enum_text(&request.mode, "mode")?) + .bind(request.created_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn finish_yaml_import_job( + &self, + job_id: &YamlImportJobId, + completion: &YamlImportJobCompletion, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update yaml_import_jobs + set status = $1, + result_operation_id = $2, + result_version = $3, + error_text = $4, + finished_at = $5::timestamptz + where id = $6", + ) + .bind(serialize_enum_text(&completion.status, "status")?) + .bind( + completion + .result_operation_id + .as_ref() + .map(|value| value.as_str()), + ) + .bind(completion.result_version.map(to_db_version)) + .bind(completion.error_text.as_deref()) + .bind(&completion.finished_at) + .bind(job_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::YamlImportJobNotFound { + job_id: job_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn get_yaml_import_job( + &self, + job_id: &YamlImportJobId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + source_sample_id, + status, + format_version, + mode, + result_operation_id, + result_version, + error_text, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(finished_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as finished_at + from yaml_import_jobs + where id = $1", + ) + .bind(job_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_yaml_import_job).transpose() + } +} + +async fn insert_version_row( + tx: &mut Transaction<'_, Postgres>, + snapshot: &RegistryOperation, + change_note: Option<&str>, + created_by: Option<&str>, +) -> Result<(), RegistryError> { + sqlx::query( + "insert into operation_versions ( + operation_id, + version, + status, + target_json, + input_schema_json, + output_schema_json, + input_mapping_json, + output_mapping_json, + execution_config_json, + tool_description_json, + samples_json, + generated_draft_json, + config_export_json, + change_note, + created_at, + created_by + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15::timestamptz, $16 + )", + ) + .bind(snapshot.id.as_str()) + .bind(to_db_version(snapshot.version)) + .bind(serialize_enum_text(&snapshot.status, "status")?) + .bind(Json(serialize_json_value(&snapshot.target)?)) + .bind(Json(serialize_json_value(&snapshot.input_schema)?)) + .bind(Json(serialize_json_value(&snapshot.output_schema)?)) + .bind(Json(serialize_json_value(&snapshot.input_mapping)?)) + .bind(Json(serialize_json_value(&snapshot.output_mapping)?)) + .bind(Json(serialize_json_value(&snapshot.execution_config)?)) + .bind(Json(serialize_json_value(&snapshot.tool_description)?)) + .bind(serialize_option_json_value(&snapshot.samples)?.map(Json)) + .bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json)) + .bind(serialize_option_json_value(&snapshot.config_export)?.map(Json)) + .bind(change_note) + .bind(&snapshot.updated_at) + .bind(created_by) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +fn assert_immutable_fields( + summary: &OperationSummary, + snapshot: &RegistryOperation, +) -> Result<(), RegistryError> { + if summary.name != snapshot.name { + return Err(RegistryError::ImmutableOperationFieldChanged { + operation_id: snapshot.id.as_str().to_owned(), + field: "name", + }); + } + + if summary.display_name != snapshot.display_name { + return Err(RegistryError::ImmutableOperationFieldChanged { + operation_id: snapshot.id.as_str().to_owned(), + field: "display_name", + }); + } + + if summary.protocol != snapshot.protocol { + return Err(RegistryError::ImmutableOperationFieldChanged { + operation_id: snapshot.id.as_str().to_owned(), + field: "protocol", + }); + } + + Ok(()) +} + +fn map_operation_summary(row: &PgRow) -> Result { + Ok(OperationSummary { + id: OperationId::new(row.try_get::("id")?), + name: row.try_get("name")?, + display_name: row.try_get("display_name")?, + protocol: deserialize_enum_text(&row.try_get::("protocol")?, "protocol")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + current_draft_version: from_db_version( + row.try_get("current_draft_version")?, + "current_draft_version", + )?, + latest_published_version: row + .try_get::, _>("latest_published_version")? + .map(|value| from_db_version(value, "latest_published_version")) + .transpose()?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + published_at: row.try_get("published_at")?, + }) +} + +fn map_operation_version_record(row: &PgRow) -> Result { + let operation_id = OperationId::new(row.try_get::("id")?); + let version = from_db_version(row.try_get("version")?, "version")?; + let status = deserialize_enum_text(&row.try_get::("status")?, "status")?; + + Ok(OperationVersionRecord { + operation_id: operation_id.clone(), + version, + status, + change_note: row.try_get("change_note")?, + created_at: row.try_get("created_at")?, + created_by: row.try_get("created_by")?, + snapshot: RegistryOperation { + id: operation_id, + name: row.try_get("name")?, + display_name: row.try_get("display_name")?, + protocol: deserialize_enum_text(&row.try_get::("protocol")?, "protocol")?, + status, + version, + target: deserialize_json_value(row.try_get::, _>("target_json")?.0)?, + input_schema: deserialize_json_value( + row.try_get::, _>("input_schema_json")?.0, + )?, + output_schema: deserialize_json_value( + row.try_get::, _>("output_schema_json")?.0, + )?, + input_mapping: deserialize_json_value( + row.try_get::, _>("input_mapping_json")?.0, + )?, + output_mapping: deserialize_json_value( + row.try_get::, _>("output_mapping_json")?.0, + )?, + execution_config: deserialize_json_value( + row.try_get::, _>("execution_config_json")?.0, + )?, + tool_description: deserialize_json_value( + row.try_get::, _>("tool_description_json")?.0, + )?, + samples: row + .try_get::>, _>("samples_json")? + .map(|value| deserialize_json_value(value.0)) + .transpose()?, + generated_draft: row + .try_get::>, _>("generated_draft_json")? + .map(|value| deserialize_json_value(value.0)) + .transpose()?, + config_export: row + .try_get::>, _>("config_export_json")? + .map(|value| deserialize_json_value(value.0)) + .transpose()?, + created_at: row.try_get("operation_created_at")?, + updated_at: row.try_get("operation_updated_at")?, + published_at: row.try_get("operation_published_at")?, + }, + }) +} + +fn map_auth_profile(row: &PgRow) -> Result { + Ok(AuthProfile { + id: mcpaas_core::AuthProfileId::new(row.try_get::("id")?), + name: row.try_get("name")?, + kind: deserialize_enum_text(&row.try_get::("kind")?, "kind")?, + config: deserialize_json_value(row.try_get::, _>("config_json")?.0)?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +fn map_sample_metadata(row: &PgRow) -> Result { + Ok(OperationSampleMetadata { + id: mcpaas_core::SampleId::new(row.try_get::("id")?), + operation_id: OperationId::new(row.try_get::("operation_id")?), + version: from_db_version(row.try_get("version")?, "version")?, + sample_kind: deserialize_enum_text( + &row.try_get::("sample_kind")?, + "sample_kind", + )?, + storage_ref: row.try_get("storage_ref")?, + content_type: row.try_get("content_type")?, + file_name: row.try_get("file_name")?, + created_at: row.try_get("created_at")?, + }) +} + +fn map_descriptor_metadata(row: &PgRow) -> Result { + Ok(DescriptorMetadata { + id: mcpaas_core::DescriptorId::new(row.try_get::("id")?), + operation_id: row + .try_get::, _>("operation_id")? + .map(OperationId::new), + version: row + .try_get::, _>("version")? + .map(|value| from_db_version(value, "version")) + .transpose()?, + descriptor_kind: deserialize_enum_text( + &row.try_get::("descriptor_kind")?, + "descriptor_kind", + )?, + storage_ref: row.try_get("storage_ref")?, + source_name: row.try_get("source_name")?, + package_index: row + .try_get::>, _>("package_index_json")? + .map(|value| value.0), + created_at: row.try_get("created_at")?, + }) +} + +fn map_yaml_import_job(row: &PgRow) -> Result { + Ok(YamlImportJob { + id: YamlImportJobId::new(row.try_get::("id")?), + source_sample_id: row + .try_get::, _>("source_sample_id")? + .map(mcpaas_core::SampleId::new), + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + format_version: row.try_get("format_version")?, + mode: deserialize_enum_text(&row.try_get::("mode")?, "mode")?, + result_operation_id: row + .try_get::, _>("result_operation_id")? + .map(OperationId::new), + result_version: row + .try_get::, _>("result_version")? + .map(|value| from_db_version(value, "result_version")) + .transpose()?, + error_text: row.try_get("error_text")?, + created_at: row.try_get("created_at")?, + finished_at: row.try_get("finished_at")?, + }) +} + +fn serialize_json_value(value: &T) -> Result { + Ok(serde_json::to_value(value)?) +} + +fn serialize_option_json_value( + value: &Option, +) -> Result, RegistryError> { + value.as_ref().map(serialize_json_value).transpose() +} + +fn deserialize_json_value(value: Value) -> Result { + Ok(serde_json::from_value(value)?) +} + +fn serialize_enum_text( + value: &T, + field: &'static str, +) -> Result { + serde_json::to_value(value)? + .as_str() + .map(ToOwned::to_owned) + .ok_or(RegistryError::InvalidEnumRepresentation { field }) +} + +fn deserialize_enum_text( + value: &str, + field: &'static str, +) -> Result { + serde_json::from_value(Value::String(value.to_owned())) + .map_err(|_| RegistryError::InvalidEnumRepresentation { field }) +} + +fn to_db_version(value: u32) -> i32 { + value as i32 +} + +fn from_db_version(value: i32, field: &'static str) -> Result { + u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue { + field, + value: i64::from(value), + }) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + env, + time::{SystemTime, UNIX_EPOCH}, + }; + + use mcpaas_core::{ + ApiKeyHeaderAuthConfig, AuthConfig, AuthKind, AuthProfile, ConfigExport, ExecutionConfig, + ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, OperationId, OperationStatus, + Protocol, RestTarget, RetryPolicy, Samples, SecretRef, Target, ToolDescription, + ToolExample, + }; + use mcpaas_mapping::{MappingRule, MappingSet}; + use mcpaas_schema::{Schema, SchemaKind}; + use serde_json::json; + use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; + + use crate::{ + PostgresRegistry, RegistryError, + model::{ + CreateVersionRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, + OperationSampleMetadata, PublishRequest, RegistryOperation, SampleKind, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + }, + }; + + #[tokio::test] + async fn stores_versions_and_published_operations() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation_v1 = test_operation("op_rest_01", 1, OperationStatus::Draft); + + registry + .create_operation(&operation_v1, Some("alice")) + .await + .unwrap(); + + let operation_v2 = test_operation("op_rest_01", 2, OperationStatus::Draft); + + registry + .create_version(CreateVersionRequest { + snapshot: &operation_v2, + change_note: Some("add output mapping"), + created_by: Some("alice"), + }) + .await + .unwrap(); + + registry + .publish_operation(PublishRequest { + operation_id: &operation_v2.id, + version: operation_v2.version, + published_at: "2026-03-25T12:10:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + + let summary = registry + .get_operation_summary(&operation_v2.id) + .await + .unwrap() + .unwrap(); + let versions = registry + .list_operation_versions(&operation_v2.id) + .await + .unwrap(); + let published = registry + .get_published_operation(&operation_v2.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(summary.current_draft_version, 2); + assert_eq!(summary.latest_published_version, Some(2)); + assert_eq!(summary.status, OperationStatus::Published); + assert_eq!(versions.len(), 2); + assert_eq!( + versions[1].change_note.as_deref(), + Some("add output mapping") + ); + assert_eq!(published.version, 2); + assert!(published.is_published()); + + database.cleanup().await; + } + + #[tokio::test] + async fn rejects_out_of_order_versions() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_rest_02", 1, OperationStatus::Draft); + + registry.create_operation(&operation, None).await.unwrap(); + + let invalid = test_operation("op_rest_02", 3, OperationStatus::Draft); + let error = registry + .create_version(CreateVersionRequest { + snapshot: &invalid, + change_note: None, + created_by: None, + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RegistryError::InvalidVersionSequence { + expected: 2, + actual: 3, + .. + } + )); + + database.cleanup().await; + } + + #[tokio::test] + async fn stores_auth_profiles_and_artifact_metadata() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_rest_03", 1, OperationStatus::Draft); + + registry.create_operation(&operation, None).await.unwrap(); + + let auth_profile = AuthProfile { + id: "auth_rmcp".into(), + name: "RMCP API key".to_owned(), + kind: AuthKind::ApiKeyHeader, + config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig { + header_name: "X-Api-Key".to_owned(), + secret_ref: SecretRef::new("vault://rmcp/api-key"), + }), + created_at: "2026-03-25T12:00:00Z".to_owned(), + updated_at: "2026-03-25T12:00:00Z".to_owned(), + }; + + registry + .save_auth_profile(SaveAuthProfileRequest { + profile: &auth_profile, + }) + .await + .unwrap(); + + let input_sample = OperationSampleMetadata { + id: "sample_input".into(), + operation_id: operation.id.clone(), + version: 1, + sample_kind: SampleKind::InputJson, + storage_ref: "file:///tmp/input.json".to_owned(), + content_type: "application/json".to_owned(), + file_name: Some("input.json".to_owned()), + created_at: "2026-03-25T12:01:00Z".to_owned(), + }; + let descriptor = DescriptorMetadata { + id: "descriptor_01".into(), + operation_id: Some(operation.id.clone()), + version: Some(1), + descriptor_kind: DescriptorKind::DescriptorSet, + storage_ref: "file:///tmp/schema.desc".to_owned(), + source_name: Some("schema.desc".to_owned()), + package_index: Some(json!({ "crm.v1": ["LeadService"] })), + created_at: "2026-03-25T12:02:00Z".to_owned(), + }; + + registry + .save_sample_metadata(SaveSampleMetadataRequest { + sample: &input_sample, + }) + .await + .unwrap(); + registry + .save_descriptor_metadata(SaveDescriptorMetadataRequest { + descriptor: &descriptor, + }) + .await + .unwrap(); + + let auth_profiles = registry.list_auth_profiles().await.unwrap(); + let samples = registry + .list_sample_metadata(&operation.id, 1) + .await + .unwrap(); + let descriptors = registry + .list_descriptor_metadata(&operation.id, 1) + .await + .unwrap(); + + assert_eq!(auth_profiles, vec![auth_profile]); + assert_eq!(samples, vec![input_sample]); + assert_eq!(descriptors, vec![descriptor]); + + database.cleanup().await; + } + + #[tokio::test] + async fn stores_and_finishes_yaml_import_jobs() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let job_id = YamlImportJobId::new("job_yaml_01"); + let operation = test_operation("op_rest_04", 1, OperationStatus::Draft); + + registry.create_operation(&operation, None).await.unwrap(); + + registry + .create_yaml_import_job(CreateYamlImportJobRequest { + id: &job_id, + source_sample_id: None, + format_version: "v1", + mode: ExportMode::Portable, + created_at: "2026-03-25T12:00:00Z", + }) + .await + .unwrap(); + + registry + .finish_yaml_import_job( + &job_id, + &YamlImportJobCompletion { + status: YamlImportJobStatus::Completed, + result_operation_id: Some(operation.id.clone()), + result_version: Some(2), + error_text: None, + finished_at: "2026-03-25T12:05:00Z".to_owned(), + }, + ) + .await + .unwrap(); + + let job = registry + .get_yaml_import_job(&job_id) + .await + .unwrap() + .unwrap(); + + assert_eq!(job.status, YamlImportJobStatus::Completed); + assert_eq!(job.result_version, Some(2)); + assert_eq!(job.mode, ExportMode::Portable); + + database.cleanup().await; + } + + struct TestDatabase { + admin_pool: PgPool, + database_url: String, + schema: String, + } + + impl TestDatabase { + async fn new() -> Self { + let database_url = env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must point to a reachable PostgreSQL database"); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .unwrap(); + let schema = format!( + "test_registry_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + + admin_pool + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + + Self { + admin_pool, + database_url, + schema, + } + } + + async fn registry(&self) -> PostgresRegistry { + PostgresRegistry::connect_in_schema(&self.database_url, Some(&self.schema)) + .await + .unwrap() + } + + async fn cleanup(&self) { + self.admin_pool + .execute(sqlx::query(&format!( + "drop schema if exists {} cascade", + self.schema + ))) + .await + .unwrap(); + } + } + + fn test_operation(id: &str, version: u32, status: OperationStatus) -> RegistryOperation { + RegistryOperation { + id: OperationId::new(id), + name: format!("{id}_tool"), + display_name: format!("Display {id}"), + protocol: Protocol::Rest, + status, + version, + target: Target::Rest(RestTarget { + base_url: "https://api.example.com".to_owned(), + method: HttpMethod::Post, + path_template: "/v1/leads".to_owned(), + static_headers: BTreeMap::from([("X-Static".to_owned(), "true".to_owned())]), + }), + input_schema: Schema { + kind: SchemaKind::Object, + description: Some("input".to_owned()), + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "email".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + output_schema: Schema { + kind: SchemaKind::Object, + description: Some("output".to_owned()), + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + "id".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 10_000, + retry_policy: Some(RetryPolicy { max_attempts: 3 }), + auth_profile_ref: Some("auth_rmcp".into()), + headers: BTreeMap::from([("X-Request-Id".to_owned(), "static".to_owned())]), + protocol_options: None, + }, + tool_description: ToolDescription { + title: "Create lead".to_owned(), + description: "Creates CRM lead".to_owned(), + tags: vec!["crm".to_owned()], + examples: vec![ToolExample { + input: json!({ "email": "a@example.com" }), + }], + }, + samples: Some(Samples { + input_json_sample_ref: Some("sample_input".into()), + output_json_sample_ref: Some("sample_output".into()), + proto_file_ref: None, + descriptor_ref: None, + }), + generated_draft: Some(GeneratedDraft { + status: GeneratedDraftStatus::Available, + source_types: vec!["input_json".to_owned(), "output_json".to_owned()], + generated_at: Some("2026-03-25T11:59:00Z".to_owned()), + input_schema_generated: true, + output_schema_generated: true, + input_mapping_generated: true, + output_mapping_generated: true, + warnings: Vec::new(), + }), + config_export: Some(ConfigExport { + format_version: "v1".to_owned(), + export_mode: ExportMode::Portable, + }), + created_at: "2026-03-25T11:58:00Z".to_owned(), + updated_at: format!("2026-03-25T12:{version:02}:00Z"), + published_at: None, + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index b377b3e..ff2dedf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rmcp} volumes: - postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rmcp} -d ${POSTGRES_DB:-rmcp}"] interval: 10s diff --git a/docs/architecture.md b/docs/architecture.md index 1cb14f1..f1f6b2c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -268,6 +268,12 @@ gRPC target: Черновой mapping может генерироваться автоматически на основе загруженных примеров данных, но итоговая конфигурация всегда остается явной и редактируемой оператором. +Для MVP допустима простая стратегия draft generation: + +- искать уникальные leaf-поля с одинаковыми именами; +- предлагать только однозначные соответствия; +- не пытаться автоматически разрешать конфликты и неоднозначности. + Каноническая логическая модель остается общей для runtime и БД, но система должна уметь сериализовать и десериализовать ее также в `YAML`. ## 9. Основные компоненты @@ -428,7 +434,7 @@ mcpaas/ - `tokio` как async runtime - `axum` для HTTP API - `serde` и `serde_json` -- `sqlx` для PostgreSQL или SQLite +- `sqlx` для PostgreSQL - `reqwest` для REST и GraphQL транспорта - `tonic` и `prost` для работы с gRPC - `tower` для middleware diff --git a/docs/data-model.md b/docs/data-model.md index 9dff2e7..d9a805d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -383,6 +383,13 @@ - `$.response.grpc.*` - `$.output.*` +Для MVP достаточно управляемого подмножества `JSONPath`: + +- путь всегда начинается с фиксированного root context; +- дальше используются dot-separated поля; +- для массивов допускаются numeric indexes вида `[0]`; +- quoted selectors, filter expressions и произвольные функции не поддерживаются. + ### `Transform` Для MVP transformations должны быть ограничены: @@ -404,6 +411,14 @@ } ``` +### Черновая генерация mapping + +Для MVP generation draft mapping может опираться на простое правило: + +- система сопоставляет уникальные leaf-поля с одинаковыми именами в source sample и target sample; +- неоднозначные совпадения автоматически не связываются; +- результат всегда остается черновиком и требует ручной проверки оператором. + ## 7. `ExecutionConfig` `ExecutionConfig` задает параметры выполнения operation. diff --git a/docs/database-schema.md b/docs/database-schema.md index c983e36..83275c2 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -4,7 +4,7 @@ Этот документ фиксирует структуру хранения конфигураций, версий операций, загруженных артефактов и published runtime-view. Его цель - дать основу для SQL-миграций и для реализации `mcpaas-registry`. -В документе предполагается реляционная модель, ориентированная на `PostgreSQL`. Для MVP допускается адаптация под `SQLite`, но канонической считается схема, совместимая с `PostgreSQL`. +В документе предполагается реляционная модель, ориентированная на `PostgreSQL`. Канонической считается схема, совместимая с `PostgreSQL`. ## 2. Общие принципы хранения @@ -12,6 +12,8 @@ Конфигурация operation не должна храниться только в одной "живой" записи. Каждое существенное изменение должно приводить к появлению новой версии конфигурации. +Для MVP в registry version snapshot хранит protocol-specific конфигурацию, схемы, mapping и execution settings. Поля identity и listing view (`name`, `display_name`, `protocol`) считаются стабильными и хранятся в `operations`. + ### 2.2. Published и draft разделяются логически - `draft` может меняться; @@ -28,6 +30,14 @@ Для MVP рекомендуется отдельная таблица `auth_profiles`, где metadata и secret refs отделены от operation versions. +### 2.5. Тестовая изоляция + +Integration tests для registry должны выполняться на реальной `PostgreSQL`, но без влияния на runtime-данные. Предпочтительный способ: + +- отдельная test database; +- либо отдельная временная schema на время теста; +- обязательная очистка после завершения тестов. + ## 3. Основные таблицы Минимальный набор таблиц: diff --git a/docs/diagrams.md b/docs/diagrams.md index 4d3a301..59745f8 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -27,7 +27,7 @@ flowchart LR REST[adapter-rest] GQL[adapter-graphql] GRPC[adapter-grpc] - DB[(PostgreSQL/SQLite)] + DB[(PostgreSQL)] STORE[(Artifact Storage)] UI --> ADMIN diff --git a/docs/module-decomposition.md b/docs/module-decomposition.md index acdd833..77f2841 100644 --- a/docs/module-decomposition.md +++ b/docs/module-decomposition.md @@ -311,7 +311,6 @@ mcpaas-registry/ get_runtime_view.rs storage/ mod.rs - sqlite.rs postgres.rs cache/ mod.rs diff --git a/docs/runtime-config.md b/docs/runtime-config.md index cd2a83e..cff4e8f 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -9,7 +9,7 @@ ## 2. Базовые решения для MVP - каноническая БД: `PostgreSQL` -- допустимый упрощенный режим разработки: `SQLite` +- локальная разработка и тесты используют ту же `PostgreSQL`-модель хранения - artifact storage: локальная файловая система - MCP transport: `Streamable HTTP` - admin API и mcp-server запускаются как отдельные приложения @@ -100,7 +100,7 @@ var/mcpaas/ Local development: -- `SQLite` допустим; +- локальное окружение должно иметь доступ к `PostgreSQL`; - локальный storage; - упрощенная auth-модель admin-api.