diff --git a/proto/trogonai/grpc_nats_micro/v1/echo.proto b/proto/trogonai/grpc_nats_micro/v1/echo.proto new file mode 100644 index 0000000000..99d767691c --- /dev/null +++ b/proto/trogonai/grpc_nats_micro/v1/echo.proto @@ -0,0 +1,50 @@ +edition = "2024"; + +// Reference service for the grpc-nats-micro binding (ADR 0016). It is the +// conformance fixture the binding's integration tests drive, and a worked +// example of a protobuf service annotated for NATS micro. It carries no domain +// meaning. +package trogonai.grpc_nats_micro.v1; + +import "google/rpc/code.proto"; +import "google/rpc/status.proto"; +import "trogon/nats/micro/v1alpha1/options.proto"; + +// EchoService is the reference NATS micro service. The binding derives one +// endpoint per rpc; success replies carry the response message, faults carry +// a google.rpc.Status on the micro error channel (ADR 0016 section 3). +service EchoService { + option (trogon.nats.micro.v1alpha1.service) = {version: "1.0.0"}; + + // Say returns the request message unchanged. + rpc Say(SayRequest) returns (SayResponse); + + // Fail always faults, so tests can exercise the google.rpc.Status error + // channel. The requested code is echoed back as the fault's status code. + rpc Fail(FailRequest) returns (FailResponse) { + option (trogon.nats.micro.v1alpha1.method) = { + metadata: { + key: "always-faults" + value: "true" + } + }; + } +} + +message SayRequest { + string message = 1; +} + +message SayResponse { + string message = 1; +} + +message FailRequest { + // Canonical status code the handler should fault with. + google.rpc.Code code = 1; + string message = 2; +} + +message FailResponse { + string message = 1; +} diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 7a9d682230..0f92e68f92 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -64,6 +64,52 @@ dependencies = [ "wiremock", ] +[[package]] +name = "a2a-gateway" +version = "0.1.0" +dependencies = [ + "a2a-auth-callout", + "a2a-lf", + "a2a-nats", + "a2a-pack", + "a2a-redaction", + "async-nats", + "async-trait", + "axum", + "base64 0.23.1", + "bytes", + "cel-interpreter", + "clap", + "filetime", + "futures", + "jsonwebtoken 10.4.0", + "nkeys", + "p256", + "rand_core 0.6.4", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.11.0", + "spicedb-grpc-tonic", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-util", + "toml 1.1.4+spec-1.1.0", + "tonic", + "tracing", + "tracing-subscriber", + "trogon-aauth-person", + "trogon-aauth-sdk", + "trogon-aauth-verify", + "trogon-identity-types", + "trogon-jwks-publisher", + "trogon-nats", + "trogon-std", + "uuid", +] + [[package]] name = "a2a-identity-types" version = "0.1.0" @@ -123,6 +169,71 @@ dependencies = [ "wiremock", ] +[[package]] +name = "a2a-nats-http" +version = "0.1.0" +dependencies = [ + "a2a-identity-types", + "a2a-lf", + "a2a-nats", + "async-compat", + "async-nats", + "async-trait", + "axum", + "base64 0.23.1", + "bytes", + "futures", + "futures-util", + "jsonrpc-nats", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", +] + +[[package]] +name = "a2a-nats-server" +version = "0.1.0" +dependencies = [ + "a2a-lf", + "a2a-nats", + "async-nats", + "async-trait", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", +] + +[[package]] +name = "a2a-nats-stdio" +version = "0.1.0" +dependencies = [ + "a2a-lf", + "a2a-nats", + "async-nats", + "bytes", + "futures", + "jsonrpc-nats", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "trogon-nats", + "trogon-std", +] + [[package]] name = "a2a-pack" version = "0.1.0" @@ -177,6 +288,75 @@ dependencies = [ "uuid", ] +[[package]] +name = "acp-nats-agent" +version = "0.0.1" +dependencies = [ + "acp-nats", + "agent-client-protocol", + "async-nats", + "async-trait", + "bytes", + "futures", + "jsonrpc-nats", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", +] + +[[package]] +name = "acp-nats-server" +version = "0.1.0" +dependencies = [ + "acp-nats", + "agent-client-protocol", + "agent-client-protocol-http", + "anyhow", + "async-nats", + "axum", + "bytes", + "clap", + "futures-util", + "opentelemetry", + "reqwest 0.12.28", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tower", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", + "trogon-telemetry", +] + +[[package]] +name = "acp-nats-stdio" +version = "0.0.1" +dependencies = [ + "acp-nats", + "agent-client-protocol", + "anyhow", + "async-compat", + "async-nats", + "bytes", + "clap", + "futures", + "opentelemetry", + "tokio", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", + "trogon-telemetry", +] + [[package]] name = "addr2line" version = "0.26.1" @@ -186,6 +366,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -230,6 +416,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "agent-client-protocol-http" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4d8db045bc66b84526dfe4ef2ffb87d651b81c4ff945f91c63a2bc677a582c" +dependencies = [ + "agent-client-protocol", + "async-stream", + "axum", + "futures", + "serde_json", + "tokio", + "tower-http 0.7.0", + "tracing", + "uuid", +] + [[package]] name = "agent-client-protocol-schema" version = "1.5.0" @@ -269,6 +472,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -334,6 +552,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "antlr4rust" +version = "0.3.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d240d49ee89063f90fa0cb18aead41a5893cd544a1785983dc3bf5c3d5faa58b" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -500,6 +735,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-io" version = "2.6.0" @@ -558,7 +805,7 @@ dependencies = [ "tokio-rustls", "tokio-stream", "tokio-util", - "tokio-websockets", + "tokio-websockets 0.10.1", "tracing", "tryhard", "url", @@ -639,6 +886,15 @@ dependencies = [ "syn 3.0.3", ] +[[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" @@ -681,6 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -700,7 +957,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -729,6 +986,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -753,6 +1021,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bit-set" version = "0.8.0" @@ -788,6 +1062,9 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -900,6 +1177,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -962,6 +1260,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -989,6 +1293,31 @@ dependencies = [ "shlex", ] +[[package]] +name = "cel-interpreter" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76c07820046cc8239526fceec6df147a979ae48644dbad274fc3ce38ab0973b" +dependencies = [ + "cel-parser", + "chrono", + "nom", + "paste", + "regex", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cel-parser" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546fb134998490c5c47fc7a29c7535e725d2e403f172040e8f263d0b318bff5f" +dependencies = [ + "antlr4rust", + "lazy_static", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1158,6 +1487,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1412,6 +1761,21 @@ version = "0.134.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6977c2a71ab1e0d1e62f966b411a498aa04c4dce47d93d52f8a360a06058922" +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1461,6 +1825,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1873,6 +2246,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dptree" version = "0.5.1" @@ -1940,6 +2319,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -1953,7 +2335,7 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -2093,6 +2475,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2111,6 +2503,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2122,6 +2525,17 @@ dependencies = [ "serde", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2220,6 +2634,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -2376,6 +2801,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "grpc-nats-micro" +version = "0.1.0" +dependencies = [ + "async-nats", + "buffa", + "buffa-types", + "bytes", + "futures-util", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "trogon-nats", + "trogonai-proto", +] + [[package]] name = "h2" version = "0.4.14" @@ -2416,6 +2860,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashbrown" @@ -2430,6 +2879,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -2457,6 +2915,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3146,6 +3613,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3276,6 +3753,16 @@ dependencies = [ "trogon-telemetry", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3319,6 +3806,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -3356,6 +3853,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "nats-jwt-rs" version = "0.1.1" @@ -3654,6 +4160,15 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.2" @@ -3738,6 +4253,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.2" @@ -4956,6 +5477,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -5109,6 +5640,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -5190,6 +5732,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -5273,19 +5821,197 @@ dependencies = [ ] [[package]] -name = "spin" -version = "0.9.8" +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.2", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] [[package]] -name = "spki" -version = "0.7.3" +name = "sqlx-sqlite" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ - "base64ct", - "der", + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", ] [[package]] @@ -5320,6 +6046,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "strsim" version = "0.11.1" @@ -5697,6 +6434,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5744,7 +6482,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -5783,6 +6525,28 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "fastrand", + "futures-core", + "futures-sink", + "http", + "httparse", + "rustls-native-certs", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -5958,12 +6722,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ + "async-compression", "bitflags 2.13.0", "bytes", + "futures-core", "http", "http-body", "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -6072,6 +6840,50 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "trogon-aauth-as" +version = "0.0.1" +dependencies = [ + "async-trait", + "axum", + "base64 0.23.1", + "jsonwebtoken 10.4.0", + "p256", + "pkcs8", + "rand_core 0.6.4", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tower", + "trogon-aauth-verify", + "trogon-identity-types", + "uuid", +] + +[[package]] +name = "trogon-aauth-person" +version = "0.0.1" +dependencies = [ + "async-trait", + "axum", + "base64 0.23.1", + "jsonwebtoken 10.4.0", + "p256", + "pkcs8", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "tower", + "trogon-aauth-verify", + "trogon-identity-types", + "uuid", + "wiremock", +] + [[package]] name = "trogon-aauth-sdk" version = "0.0.1" @@ -6339,6 +7151,46 @@ dependencies = [ "wit-bindgen 0.61.1", ] +[[package]] +name = "trogon-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "axum", + "base64 0.22.1", + "bytes", + "clap", + "confique", + "form_urlencoded", + "futures-core", + "futures-util", + "hex", + "hmac 0.13.0", + "reqwest 0.12.28", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-tungstenite", + "tower", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-semconv", + "trogon-service-config", + "trogon-std", + "trogon-telemetry", + "twilight-gateway", + "twilight-model", + "url", +] + [[package]] name = "trogon-identity-types" version = "0.1.0" @@ -6390,6 +7242,43 @@ dependencies = [ "uuid", ] +[[package]] +name = "trogon-scheduler" +version = "0.1.0" +dependencies = [ + "async-nats", + "buffa", + "buffa-types", + "bytes", + "chrono", + "chrono-tz", + "cron", + "futures", + "opentelemetry", + "opentelemetry_sdk", + "proptest", + "rrule", + "serde", + "serde_json", + "sqlx", + "testcontainers-modules", + "thiserror 2.0.20", + "time", + "tokio", + "tracing", + "tracing-opentelemetry", + "trogon-decider", + "trogon-decider-nats", + "trogon-decider-runtime", + "trogon-nats", + "trogon-scheduler-domain", + "trogon-semconv", + "trogon-std", + "trogon-telemetry", + "trogonai-proto", + "uuid", +] + [[package]] name = "trogon-scheduler-domain" version = "0.1.0" @@ -6533,10 +7422,60 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "sha1", + "rustls", + "rustls-pki-types", + "sha1 0.10.6", "thiserror 2.0.20", ] +[[package]] +name = "twilight-gateway" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59267541c31f888c1587da5e7cbab182ae6efd98faedd9ea2e35a4eef43ff204" +dependencies = [ + "bitflags 2.13.0", + "fastrand", + "futures-core", + "futures-sink", + "serde", + "serde_json", + "tokio", + "tokio-websockets 0.13.3", + "tracing", + "twilight-gateway-queue", + "twilight-model", +] + +[[package]] +name = "twilight-gateway-queue" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366a73fe47f61a3d522c3aaf70475e60634b0ae59e7b94272ed7496fffa7ceb7" +dependencies = [ + "tokio", + "tracing", +] + +[[package]] +name = "twilight-model" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf6bb7b93a7f765d89b3388cc710c0ae16104579e06bb30ea1ee6bd41420a8b" +dependencies = [ + "bitflags 2.13.0", + "serde", + "serde-value", + "serde_repr", + "time", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.0" @@ -6555,6 +7494,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -6567,6 +7512,21 @@ 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 = "unicode-segmentation" version = "1.13.2" @@ -6693,6 +7653,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -7253,6 +8219,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -7896,6 +8868,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index ff4c41c4b3..6be7aeecac 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -25,6 +25,7 @@ ard-nats = { path = "crates/ard/ard-nats" } ard-registry = { path = "crates/ard/ard-registry" } a2a-redaction = { path = "crates/a2a/a2a-redaction" } acp-nats = { path = "crates/acp/acp-nats" } +grpc-nats-micro = { path = "crates/platform/grpc-nats-micro" } trogon-telemetry = { path = "crates/platform/trogon-telemetry" } mcp-nats = { path = "crates/mcp/mcp-nats" } mcp-nats-server = { path = "crates/mcp/mcp-nats-server" } @@ -112,6 +113,7 @@ teloxide = { version = "=0.17.0", default-features = false, features = ["macros" # Serialization confique = { version = "=0.4.0", features = ["toml"] } serde = { version = "=1.0.229", features = ["derive"] } +semver = "=1.0.28" serde_json = "=1.0.151" jsonschema = { version = "=0.49.4", default-features = false } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml new file mode 100644 index 0000000000..3817710ead --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "grpc-nats-micro" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Protocol Buffers request/reply over NATS micro (ADR 0016)" + +[lints] +workspace = true + +[dependencies] +async-nats = { workspace = true, features = ["service"] } +buffa = { workspace = true } +bytes = { workspace = true } +futures-util = { workspace = true } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["time", "rt"] } +tracing = { workspace = true } +trogon-nats = { workspace = true } +trogonai-proto = { workspace = true, features = ["grpc-nats-micro"] } + +[dev-dependencies] +buffa-types = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } +trogon-nats = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs new file mode 100644 index 0000000000..8222ce283c --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -0,0 +1,134 @@ +//! Binding descriptors: the annotated protobuf service and its `rpc` methods, +//! bound to NATS micro per ADR 0016 §1 and §2. + +use crate::discovery_metadata::DiscoveryMetadata; +use crate::endpoint_subject::{EndpointSubject, EndpointSubjectError}; +use crate::method_name::MethodName; +use crate::service_name::ServiceName; +use crate::service_version::ServiceVersion; +use crate::subject_prefix::SubjectPrefix; + +/// One `rpc` method of the annotated protobuf service, registered as a micro +/// endpoint on its derived subject. +#[derive(Debug, Clone)] +pub struct EndpointBinding { + method_name: MethodName, + subject: EndpointSubject, + metadata: DiscoveryMetadata, +} + +impl EndpointBinding { + pub fn new( + subject_prefix: &SubjectPrefix, + service_name: &ServiceName, + method_name: MethodName, + metadata: DiscoveryMetadata, + ) -> Result { + let subject = EndpointSubject::new(subject_prefix, service_name, &method_name)?; + Ok(Self { + method_name, + subject, + metadata, + }) + } + + pub fn method_name(&self) -> &MethodName { + &self.method_name + } + + pub fn subject(&self) -> &EndpointSubject { + &self.subject + } + + /// `MethodOptions.metadata`, which populates this endpoint's discovery + /// record (ADR 0016 §1). + pub const fn metadata(&self) -> &DiscoveryMetadata { + &self.metadata + } +} + +/// The annotated protobuf service registered as one NATS micro service +/// (ADR 0016 §1), and the subject prefix its endpoints are derived under. +#[derive(Debug, Clone)] +pub struct ServiceBinding { + name: ServiceName, + version: ServiceVersion, + description: Option, + metadata: DiscoveryMetadata, + subject_prefix: SubjectPrefix, + endpoints: Vec, +} + +impl ServiceBinding { + pub fn new(name: ServiceName, version: ServiceVersion, subject_prefix: SubjectPrefix) -> Self { + Self { + name, + version, + description: None, + metadata: DiscoveryMetadata::default(), + subject_prefix, + endpoints: Vec::new(), + } + } + + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + pub fn with_metadata(mut self, metadata: DiscoveryMetadata) -> Self { + self.metadata = metadata; + self + } + + /// Register an `rpc` method as a micro endpoint, deriving its subject + /// from this binding's subject prefix and service name. + /// + /// `metadata` is the method's own `MethodOptions.metadata`. Every endpoint + /// has a discovery record, so a method that declares none passes an empty + /// map rather than leaving the argument out. + pub fn with_method( + mut self, + method_name: MethodName, + metadata: DiscoveryMetadata, + ) -> Result { + self.endpoints.push(EndpointBinding::new( + &self.subject_prefix, + &self.name, + method_name, + metadata, + )?); + Ok(self) + } + + pub fn name(&self) -> &ServiceName { + &self.name + } + + pub const fn version(&self) -> &ServiceVersion { + &self.version + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// `ServiceOptions.metadata`, which populates this service's discovery + /// record (ADR 0016 §1). + pub const fn metadata(&self) -> &DiscoveryMetadata { + &self.metadata + } + + pub fn subject_prefix(&self) -> &SubjectPrefix { + &self.subject_prefix + } + + pub fn endpoints(&self) -> &[EndpointBinding] { + &self.endpoints + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs new file mode 100644 index 0000000000..2dc73dde70 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -0,0 +1,66 @@ +use super::ServiceBinding; +use crate::discovery_metadata::DiscoveryMetadata; +use crate::method_name::MethodName; +use crate::method_name_input::MethodNameInput; +use crate::service_name::ServiceName; +use crate::service_name_input::ServiceNameInput; +use crate::service_version::ServiceVersion; +use crate::service_version_input::ServiceVersionInput; +use crate::subject_prefix::SubjectPrefix; +use crate::subject_prefix_input::SubjectPrefixInput; + +const SUBJECT_PREFIX: &str = "echo.v1"; + +fn binding() -> ServiceBinding { + ServiceBinding::new( + ServiceName::from_input(&ServiceNameInput::new("EchoService")).expect("valid service name"), + ServiceVersion::from_input(&ServiceVersionInput::new("1.0.0")).expect("valid service version"), + SubjectPrefix::from_input(&SubjectPrefixInput::new(SUBJECT_PREFIX)).expect("valid subject prefix"), + ) + .with_method( + MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + DiscoveryMetadata::default(), + ) + .expect("derive the Say subject") +} + +#[test] +fn derives_endpoint_subjects_under_its_own_prefix() { + let binding = binding(); + + assert_eq!(binding.subject_prefix().as_str(), SUBJECT_PREFIX); + let endpoint = binding.endpoints().first().expect("the Say endpoint is registered"); + assert_eq!(endpoint.method_name().as_str(), "Say"); + assert_eq!(endpoint.subject().as_str(), "echo.v1.EchoService.Say"); +} + +#[test] +fn carries_the_description_micro_discovery_reports() { + assert_eq!(binding().description(), None); + assert_eq!( + binding().with_description("Echoes what it is told").description(), + Some("Echoes what it is told") + ); +} + +/// The derivation is what can fail, so registering a method has to surface +/// that failure rather than register an endpoint nobody can reach. +#[test] +fn rejects_a_method_whose_subject_is_not_derivable() { + let deep = (0..trogon_nats::MAX_SUBJECT_TOKENS) + .map(|_| "a") + .collect::>() + .join("."); + let error = ServiceBinding::new( + ServiceName::from_input(&ServiceNameInput::new("EchoService")).expect("valid service name"), + ServiceVersion::from_input(&ServiceVersionInput::new("1.0.0")).expect("valid service version"), + SubjectPrefix::from_input(&SubjectPrefixInput::new(deep.as_str())).expect("valid subject prefix"), + ) + .with_method( + MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + DiscoveryMetadata::default(), + ) + .expect_err("a subject over the token budget is rejected"); + + assert_eq!(error.method_name.as_str(), "Say"); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs new file mode 100644 index 0000000000..6ae270766f --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs @@ -0,0 +1,79 @@ +//! Client-side request helper: encode a typed request, send it with a +//! timeout, and decode the reply per the ADR 0016 §3 error-channel rule. + +use std::time::Duration; + +use async_nats::HeaderMap; +use bytes::Bytes; +use thiserror::Error; +use trogon_nats::RequestClient; + +use crate::binding::EndpointBinding; +use crate::constants::HEADER_CONTENT_TYPE; +use crate::content_type::{ContentType, EncodeError}; +use crate::status_codec::{ReplyError, decode_reply}; + +/// `Transport` keeps the client's own error type rather than a rendered +/// string, so a caller can match on the concrete failure (`no responders`, +/// connection lost) instead of parsing a message. +#[derive(Debug, Error)] +pub enum RequestError +where + E: std::error::Error + 'static, +{ + #[error("failed to encode request payload")] + Encode(#[source] EncodeError), + #[error("NATS request to {subject} timed out")] + Timeout { subject: String }, + #[error("NATS request to {subject} failed")] + Transport { + subject: String, + #[source] + error: E, + }, + #[error(transparent)] + Reply(#[from] ReplyError), +} + +/// Send `request` to `endpoint`'s subject and decode the reply, per ADR 0016. +/// +/// Mirrors `jsonrpc_request_with_timeout`'s shape: the timeout covers the +/// full round trip, and the response is decoded through the micro +/// error-channel rule (§3) rather than inferred from body shape. +pub async fn request( + client: &N, + endpoint: &EndpointBinding, + content_type: ContentType, + request: &Req, + timeout: Duration, +) -> Result> +where + N: RequestClient, + N::RequestError: 'static, + Req: buffa::Message + serde::Serialize, + Resp: buffa::Message + serde::de::DeserializeOwned, +{ + let subject = endpoint.subject().as_str().to_string(); + let body = content_type.encode(request).map_err(RequestError::Encode)?; + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONTENT_TYPE, content_type.header_value()); + + let response = tokio::time::timeout( + timeout, + client.request_with_headers(subject.clone(), headers, Bytes::from(body)), + ) + .await + .map_err(|_| RequestError::Timeout { + subject: subject.clone(), + })? + .map_err(|error| RequestError::Transport { + subject: subject.clone(), + error, + })?; + + decode_reply(response.headers.as_ref(), &response.payload, content_type).map_err(RequestError::from) +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs new file mode 100644 index 0000000000..c7d5dbfca6 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs @@ -0,0 +1,144 @@ +use std::time::Duration; + +use async_nats::HeaderMap; +use buffa::Enumeration as _; +use bytes::Bytes; +use trogon_nats::AdvancedMockNatsClient; +use trogonai_proto::google::rpc::{Code, Status}; +use trogonai_proto::grpc_nats_micro::v1::{SayRequest, SayResponse}; + +use super::{RequestError, request}; +use crate::binding::{EndpointBinding, ServiceBinding}; +use crate::constants::HEADER_ERROR_CODE; +use crate::content_type::ContentType; +use crate::discovery_metadata::DiscoveryMetadata; +use crate::method_name::MethodName; +use crate::method_name_input::MethodNameInput; +use crate::service_name::ServiceName; +use crate::service_name_input::ServiceNameInput; +use crate::service_version::ServiceVersion; +use crate::service_version_input::ServiceVersionInput; +use crate::subject_prefix::SubjectPrefix; +use crate::subject_prefix_input::SubjectPrefixInput; + +const SAY_SUBJECT: &str = "echo.v1.EchoService.Say"; +const REQUEST_TIMEOUT: Duration = Duration::from_millis(50); + +fn binding() -> ServiceBinding { + ServiceBinding::new( + ServiceName::from_input(&ServiceNameInput::new("EchoService")).expect("valid service name"), + ServiceVersion::from_input(&ServiceVersionInput::new("1.0.0")).expect("valid service version"), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.v1")).expect("valid subject prefix"), + ) + .with_method( + MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + DiscoveryMetadata::default(), + ) + .expect("derive the Say subject") +} + +fn say_endpoint(binding: &ServiceBinding) -> &EndpointBinding { + binding.endpoints().first().expect("the Say endpoint is registered") +} + +fn say(message: &str) -> SayRequest { + SayRequest { + message: Some(message.to_string()), + } +} + +#[tokio::test] +async fn decodes_a_successful_reply() { + let client = AdvancedMockNatsClient::new(); + let reply = SayResponse { + message: Some("hello".to_string()), + }; + let body = ContentType::Protobuf.encode(&reply).expect("encode SayResponse"); + client.set_response_wire(SAY_SUBJECT, HeaderMap::new(), Bytes::from(body)); + let binding = binding(); + + let response: SayResponse = request( + &client, + say_endpoint(&binding), + ContentType::Protobuf, + &say("hello"), + REQUEST_TIMEOUT, + ) + .await + .expect("the reply decodes"); + + assert_eq!(response.message, Some("hello".to_string())); +} + +#[tokio::test] +async fn surfaces_a_service_error_reply() { + let client = AdvancedMockNatsClient::new(); + let status = Status { + code: Code::NOT_FOUND.to_i32(), + message: "missing".to_string(), + details: Vec::new(), + }; + let body = ContentType::Protobuf.encode(&status).expect("encode Status"); + let mut headers = HeaderMap::new(); + headers.insert(HEADER_ERROR_CODE, Code::NOT_FOUND.to_i32().to_string().as_str()); + client.set_response_wire(SAY_SUBJECT, headers, Bytes::from(body)); + let binding = binding(); + + let error = request::<_, SayRequest, SayResponse>( + &client, + say_endpoint(&binding), + ContentType::Protobuf, + &say("hello"), + REQUEST_TIMEOUT, + ) + .await + .expect_err("the reply is a service error"); + + assert!(matches!(error, RequestError::Reply(_)), "{error:?}"); +} + +/// The transport's own failure is kept rather than rendered, so a caller can +/// match on `no responders` or a lost connection instead of parsing a message. +#[tokio::test] +async fn keeps_the_transports_own_failure() { + let client = AdvancedMockNatsClient::new(); + client.fail_next_request(); + let binding = binding(); + + let error = request::<_, SayRequest, SayResponse>( + &client, + say_endpoint(&binding), + ContentType::Protobuf, + &say("hello"), + REQUEST_TIMEOUT, + ) + .await + .expect_err("the transport failed"); + + assert!( + matches!(&error, RequestError::Transport { subject, .. } if subject == SAY_SUBJECT), + "{error:?}" + ); +} + +#[tokio::test] +async fn reports_a_round_trip_that_outlives_its_deadline() { + let client = AdvancedMockNatsClient::new(); + client.hang_next_request(); + let binding = binding(); + + let error = request::<_, SayRequest, SayResponse>( + &client, + say_endpoint(&binding), + ContentType::Protobuf, + &say("hello"), + REQUEST_TIMEOUT, + ) + .await + .expect_err("the round trip outlived its deadline"); + + assert!( + matches!(&error, RequestError::Timeout { subject } if subject == SAY_SUBJECT), + "{error:?}" + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/constants.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/constants.rs new file mode 100644 index 0000000000..7a7dbf0720 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/constants.rs @@ -0,0 +1,17 @@ +/// Header present iff a reply is a micro service error (ADR 0016 §3). +pub const HEADER_ERROR_CODE: &str = "Nats-Service-Error-Code"; + +/// Header carrying the developer-facing error message; mirrors `Status.message`. +pub const HEADER_ERROR: &str = "Nats-Service-Error"; + +/// Header negotiating the request/reply payload encoding (ADR 0016 §4). +pub const HEADER_CONTENT_TYPE: &str = "Content-Type"; + +/// `Content-Type` value for the protobuf binary wire encoding. +pub const CONTENT_TYPE_PROTOBUF: &str = "application/protobuf"; + +/// `Content-Type` value for canonical proto3 JSON. +pub const CONTENT_TYPE_JSON: &str = "application/json"; + +/// Default NATS micro queue group (ADR 0016 §5). +pub const DEFAULT_QUEUE_GROUP: &str = "q"; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs new file mode 100644 index 0000000000..3836f4ee11 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -0,0 +1,122 @@ +use buffa::Message; +use thiserror::Error; +use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, ServiceOptions}; + +use crate::constants::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTOBUF}; +use crate::content_type_input::ContentTypeInput; + +/// The wire encoding used for a request or reply payload (ADR 0016 §4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentType { + Protobuf, + Json, +} + +impl ContentType { + /// The set of `Content-Type` values a [`ServiceOptions::content_type`] + /// restriction allows on the wire. + fn allowed(policy: &ServiceOptions) -> Allowed { + match policy.content_type.as_known() { + Some(ProtoContentType::CONTENT_TYPE_PROTOBUF) => Allowed::Only(Self::Protobuf), + Some(ProtoContentType::CONTENT_TYPE_JSON) => Allowed::Only(Self::Json), + Some(ProtoContentType::CONTENT_TYPE_UNSPECIFIED) | None => Allowed::Either, + } + } + + /// The encoding a `Content-Type` header names, or `None` if the value is + /// not one this binding speaks (ADR 0016 §4). The one conversion from the + /// wire value into this domain value. + pub fn from_input(input: &ContentTypeInput) -> Option { + match input.as_str() { + CONTENT_TYPE_PROTOBUF => Some(Self::Protobuf), + CONTENT_TYPE_JSON => Some(Self::Json), + _ => None, + } + } + + /// Negotiate the [`ContentType`] for a request given the service's + /// [`ServiceOptions`] restriction and the request's `Content-Type` header + /// value, if any. + /// + /// An absent header accepts either type allowed by `policy`; on ambiguity + /// (no header and both types allowed) this defaults to [`Self::Protobuf`]. + pub fn negotiate(policy: &ServiceOptions, header: Option<&ContentTypeInput>) -> Result { + let allowed = Self::allowed(policy); + match header { + Some(input) => { + let requested = Self::from_input(input).ok_or_else(|| NegotiationError::Unsupported { + requested: input.clone(), + })?; + match allowed { + Allowed::Either => Ok(requested), + Allowed::Only(only) if only == requested => Ok(requested), + Allowed::Only(_) => Err(NegotiationError::NotAllowed { requested }), + } + } + None => match allowed { + Allowed::Either => Ok(Self::Protobuf), + Allowed::Only(content_type) => Ok(content_type), + }, + } + } + + /// The `Content-Type` header value for this encoding. + pub const fn header_value(self) -> &'static str { + match self { + Self::Protobuf => CONTENT_TYPE_PROTOBUF, + Self::Json => CONTENT_TYPE_JSON, + } + } + + /// Encode a protobuf message per this content type. + pub fn encode(self, message: &M) -> Result, EncodeError> + where + M: Message + serde::Serialize, + { + match self { + Self::Protobuf => Ok(message.encode_to_vec()), + Self::Json => serde_json::to_vec(message).map_err(EncodeError::Json), + } + } + + /// Decode a protobuf message per this content type. + pub fn decode(self, bytes: &[u8]) -> Result + where + M: Message + serde::de::DeserializeOwned, + { + match self { + Self::Protobuf => M::decode_from_slice(bytes).map_err(DecodeError::Protobuf), + Self::Json => serde_json::from_slice(bytes).map_err(DecodeError::Json), + } + } +} + +enum Allowed { + Either, + Only(ContentType), +} + +#[derive(Debug, Error)] +pub enum NegotiationError { + #[error("content type {requested:?} is not allowed by the service's content-type policy")] + NotAllowed { requested: ContentType }, + #[error("unrecognized Content-Type header value: {requested}")] + Unsupported { requested: ContentTypeInput }, +} + +#[derive(Debug, Error)] +pub enum EncodeError { + #[error("failed to encode payload as JSON")] + Json(#[source] serde_json::Error), +} + +#[derive(Debug, Error)] +pub enum DecodeError { + #[error("failed to decode payload as protobuf")] + Protobuf(#[source] buffa::DecodeError), + #[error("failed to decode payload as JSON")] + Json(#[source] serde_json::Error), +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type/tests.rs new file mode 100644 index 0000000000..c0ba1250d1 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type/tests.rs @@ -0,0 +1,85 @@ +use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, ServiceOptions}; + +use super::{ContentType, NegotiationError}; +use crate::constants::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTOBUF}; +use crate::content_type_input::ContentTypeInput; + +fn policy(content_type: ProtoContentType) -> ServiceOptions { + ServiceOptions { + content_type: content_type.into(), + ..Default::default() + } +} + +#[test] +fn reads_the_header_values_this_binding_speaks() { + assert_eq!( + ContentType::from_input(&ContentTypeInput::new(CONTENT_TYPE_PROTOBUF)), + Some(ContentType::Protobuf) + ); + assert_eq!( + ContentType::from_input(&ContentTypeInput::new(CONTENT_TYPE_JSON)), + Some(ContentType::Json) + ); + assert_eq!(ContentType::from_input(&ContentTypeInput::new("application/xml")), None); +} + +#[test] +fn an_absent_header_defaults_to_protobuf_when_the_policy_allows_either() { + let negotiated = ContentType::negotiate(&ServiceOptions::default(), None).expect("either encoding is allowed"); + assert_eq!(negotiated, ContentType::Protobuf); +} + +#[test] +fn an_absent_header_takes_the_only_encoding_the_policy_allows() { + let json = ContentType::negotiate(&policy(ProtoContentType::CONTENT_TYPE_JSON), None).expect("a json-only policy"); + assert_eq!(json, ContentType::Json); + + let protobuf = + ContentType::negotiate(&policy(ProtoContentType::CONTENT_TYPE_PROTOBUF), None).expect("a protobuf-only policy"); + assert_eq!(protobuf, ContentType::Protobuf); +} + +#[test] +fn a_header_the_policy_allows_is_accepted() { + let requested = ContentTypeInput::new(CONTENT_TYPE_JSON); + + let unrestricted = + ContentType::negotiate(&ServiceOptions::default(), Some(&requested)).expect("either encoding is allowed"); + assert_eq!(unrestricted, ContentType::Json); + + let restricted = ContentType::negotiate(&policy(ProtoContentType::CONTENT_TYPE_JSON), Some(&requested)) + .expect("a json-only policy"); + assert_eq!(restricted, ContentType::Json); +} + +#[test] +fn a_header_outside_the_policy_is_rejected() { + let requested = ContentTypeInput::new(CONTENT_TYPE_JSON); + + let error = ContentType::negotiate(&policy(ProtoContentType::CONTENT_TYPE_PROTOBUF), Some(&requested)) + .expect_err("a protobuf-only policy turns a json caller away"); + + assert!(matches!( + error, + NegotiationError::NotAllowed { + requested: ContentType::Json + } + )); +} + +/// The rejection has to name what the caller actually sent, so an operator +/// reading it does not have to guess which header value was refused. +#[test] +fn a_header_the_binding_does_not_speak_is_retained_verbatim() { + let requested = ContentTypeInput::new("application/xml"); + + let error = ContentType::negotiate(&ServiceOptions::default(), Some(&requested)) + .expect_err("an unknown encoding is turned away"); + + assert!(matches!(&error, NegotiationError::Unsupported { requested: retained } if retained == &requested)); + assert_eq!( + error.to_string(), + "unrecognized Content-Type header value: application/xml" + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type_input.rs new file mode 100644 index 0000000000..eeff818980 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type_input.rs @@ -0,0 +1,23 @@ +//! The `Content-Type` header exactly as a caller sent it (ADR 0016 §4). + +/// Untrusted `Content-Type` header text. Carries no guarantee that the value +/// names an encoding this binding speaks; [`crate::ContentType::from_input`] +/// is the single conversion into the domain value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentTypeInput(Box); + +impl ContentTypeInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ContentTypeInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata.rs new file mode 100644 index 0000000000..321eba83f0 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata.rs @@ -0,0 +1,42 @@ +//! The metadata map that populates one service's or one endpoint's NATS +//! Services discovery record (ADR 0016 §1). + +use std::collections::HashMap; + +use crate::discovery_metadata_input::DiscoveryMetadataInput; + +/// Why a [`DiscoveryMetadata`] could not be constructed. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum DiscoveryMetadataError { + #[error("discovery metadata key must not be empty")] + EmptyKey, +} + +/// Metadata `$SRV.INFO` reports for a service or one of its endpoints. +/// +/// NATS Services (ADR-32) leaves the map opaque, so the only thing to +/// guarantee is that every entry is addressable: a nameless key is not +/// something a discovery consumer can ask for, and it would otherwise reach +/// `$SRV.INFO` as a silent `"": ...` entry. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiscoveryMetadata(HashMap); + +impl DiscoveryMetadata { + pub fn from_input(input: &DiscoveryMetadataInput) -> Result { + if input.entries().keys().any(|key| key.is_empty()) { + return Err(DiscoveryMetadataError::EmptyKey); + } + Ok(Self(input.entries().clone())) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub const fn entries(&self) -> &HashMap { + &self.0 + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata/tests.rs new file mode 100644 index 0000000000..ebe73447c2 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata/tests.rs @@ -0,0 +1,28 @@ +use super::{DiscoveryMetadata, DiscoveryMetadataError}; +use crate::discovery_metadata_input::DiscoveryMetadataInput; + +#[test] +fn carries_the_entries_it_was_given() { + let metadata = DiscoveryMetadata::from_input(&DiscoveryMetadataInput::new([("always-faults", "true")])) + .expect("a named entry is valid"); + + assert_eq!( + metadata.entries().get("always-faults").map(String::as_str), + Some("true") + ); +} + +#[test] +fn is_empty_without_entries() { + assert!(DiscoveryMetadata::default().is_empty()); +} + +/// A nameless key is not something a discovery consumer can ask for, so it is +/// rejected rather than published as `"": ...`. +#[test] +fn rejects_a_nameless_key() { + assert_eq!( + DiscoveryMetadata::from_input(&DiscoveryMetadataInput::new([("", "true")])), + Err(DiscoveryMetadataError::EmptyKey) + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata_input.rs new file mode 100644 index 0000000000..1fdf3ced5f --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata_input.rs @@ -0,0 +1,31 @@ +//! Discovery metadata exactly as an annotation spelled it (ADR 0016 §1). + +use std::collections::HashMap; + +/// Untrusted discovery metadata, as it arrives in +/// `trogon.nats.micro.v1alpha1.ServiceOptions.metadata` or `MethodOptions.metadata`. +/// Carries no guarantee that the entries can address anything in a discovery +/// record; [`crate::DiscoveryMetadata::from_input`] is the single conversion +/// into the domain value. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiscoveryMetadataInput(HashMap); + +impl DiscoveryMetadataInput { + pub fn new(entries: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self( + entries + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + ) + } + + pub const fn entries(&self) -> &HashMap { + &self.0 + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs new file mode 100644 index 0000000000..5828cfbd04 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs @@ -0,0 +1,65 @@ +//! The subject one `rpc` method is reachable on, derived per ADR 0016 §2 as +//! `..`. + +use std::sync::Arc; + +use trogon_nats::subject_conformance::{SubjectViolationError, validate_published_subject}; + +use crate::method_name::MethodName; +use crate::service_name::ServiceName; +use crate::subject_prefix::SubjectPrefix; + +/// Why the subject derived from otherwise valid components is not a subject +/// this binding may publish to. +/// +/// The components are kept as they were validated, so a caller can act on the +/// one that pushed the derivation over budget instead of re-parsing a rendered +/// subject. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[error("derived endpoint subject {subject_prefix}.{service_name}.{method_name} is not a conformant published subject")] +pub struct EndpointSubjectError { + pub subject_prefix: SubjectPrefix, + pub service_name: ServiceName, + pub method_name: MethodName, + #[source] + pub source: SubjectViolationError, +} + +/// A NATS subject derived from a subject prefix, service name, and method +/// name. Always constructed through [`EndpointSubject::new`], so the ADR 0016 +/// §2 derivation rule cannot drift out of sync at a call site. +/// +/// Each component validates itself, which leaves only the whole-subject +/// budget (token count, byte length) to check here. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EndpointSubject(Arc); + +impl EndpointSubject { + pub fn new( + subject_prefix: &SubjectPrefix, + service_name: &ServiceName, + method_name: &MethodName, + ) -> Result { + let subject = format!("{subject_prefix}.{service_name}.{method_name}"); + validate_published_subject(&subject).map_err(|source| EndpointSubjectError { + subject_prefix: subject_prefix.clone(), + service_name: service_name.clone(), + method_name: method_name.clone(), + source, + })?; + Ok(Self(subject.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for EndpointSubject { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs new file mode 100644 index 0000000000..7eda155886 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs @@ -0,0 +1,55 @@ +use super::EndpointSubject; +use crate::method_name::MethodName; +use crate::method_name_input::MethodNameInput; +use crate::service_name::ServiceName; +use crate::service_name_input::ServiceNameInput; +use crate::subject_prefix::SubjectPrefix; +use crate::subject_prefix_input::SubjectPrefixInput; + +fn subject(prefix: &str) -> Result { + EndpointSubject::new( + &SubjectPrefix::from_input(&SubjectPrefixInput::new(prefix)).expect("valid prefix"), + &ServiceName::from_input(&ServiceNameInput::new("EchoService")).expect("valid service name"), + &MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + ) +} + +#[test] +fn derives_prefix_service_method() { + let derived = subject("echo.v1").expect("derives a conformant subject"); + assert_eq!(derived.as_str(), "echo.v1.EchoService.Say"); +} + +#[test] +fn rejects_a_subject_over_the_token_budget() { + let deep = (0..trogon_nats::MAX_SUBJECT_TOKENS) + .map(|_| "a") + .collect::>() + .join("."); + let error = subject(&deep).expect_err("a subject over the token budget is rejected"); + assert!(matches!( + error.source, + trogon_nats::subject_conformance::SubjectViolationError::TooManyTokens { .. } + )); +} + +#[test] +fn renders_as_the_subject_it_derived() { + let derived = subject("echo.v1").expect("derives a conformant subject"); + assert_eq!(derived.to_string(), "echo.v1.EchoService.Say"); +} + +/// The rejected components stay typed, so a caller can act on the one that +/// pushed the derivation over budget. +#[test] +fn reports_the_components_it_derived_from() { + let deep = (0..trogon_nats::MAX_SUBJECT_TOKENS) + .map(|_| "a") + .collect::>() + .join("."); + let error = subject(&deep).expect_err("a subject over the token budget is rejected"); + + assert_eq!(error.subject_prefix.as_str(), deep); + assert_eq!(error.service_name.as_str(), "EchoService"); + assert_eq!(error.method_name.as_str(), "Say"); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs new file mode 100644 index 0000000000..f019843079 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -0,0 +1,53 @@ +#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] +//! Protocol Buffers request/reply over NATS micro (ADR 0016). +//! +//! This is not gRPC: there is no HTTP/2, no gRPC wire framing, and no gRPC +//! library on the request/reply path. "gRPC" in the crate name is a naming +//! idiom only; transport is a NATS micro service (NATS Services / ADR-32), +//! and the wire payload is either protobuf binary or canonical proto3 JSON, +//! negotiated per `Content-Type` (see [`content_type`]). +//! +//! See `docs/adr/0016-protobuf-rpc-over-nats-micro-binding.md` for the full +//! binding specification this crate implements. + +pub mod binding; +pub mod client; +pub mod constants; +pub mod content_type; +pub mod content_type_input; +pub mod discovery_metadata; +pub mod discovery_metadata_input; +pub mod endpoint_subject; +pub mod method_name; +pub mod method_name_input; +pub mod server; +pub mod service_error_code; +pub mod service_error_code_input; +pub mod service_fault; +pub mod service_name; +pub mod service_name_input; +pub mod service_version; +pub mod service_version_input; +pub mod status_codec; +pub mod subject_prefix; +pub mod subject_prefix_input; + +pub use binding::{EndpointBinding, ServiceBinding}; +pub use content_type::ContentType; +pub use content_type_input::ContentTypeInput; +pub use discovery_metadata::{DiscoveryMetadata, DiscoveryMetadataError}; +pub use discovery_metadata_input::DiscoveryMetadataInput; +pub use endpoint_subject::{EndpointSubject, EndpointSubjectError}; +pub use method_name::{MethodName, MethodNameError}; +pub use method_name_input::MethodNameInput; +pub use server::{EndpointHandler, ServeError, serve}; +pub use service_error_code::{ServiceErrorCode, ServiceErrorCodeError}; +pub use service_error_code_input::ServiceErrorCodeInput; +pub use service_fault::ServiceFault; +pub use service_name::{ServiceName, ServiceNameError}; +pub use service_name_input::ServiceNameInput; +pub use service_version::{ServiceVersion, ServiceVersionError}; +pub use service_version_input::ServiceVersionInput; +pub use status_codec::{EncodedReply, Outcome, ReplyError, ServiceError}; +pub use subject_prefix::{SubjectPrefix, SubjectPrefixError}; +pub use subject_prefix_input::SubjectPrefixInput; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs new file mode 100644 index 0000000000..646845abc8 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs @@ -0,0 +1,69 @@ +//! One `rpc` method's name, which becomes both the endpoint subject's final +//! token and the micro endpoint's discovery name (ADR 0016 §2). + +use trogon_nats::{NatsToken, SubjectTokenViolationError}; + +use crate::method_name_input::MethodNameInput; + +/// Why a [`MethodName`] could not be constructed. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum MethodNameError { + #[error("method name must not be empty")] + Empty, + #[error("method name must start with an ASCII letter or underscore, found {0:?}")] + LeadingCharacter(char), + #[error("method name contains invalid character: {0:?}")] + InvalidCharacter(char), + #[error("method name is too long: {0} characters")] + TooLong(usize), +} + +impl From for MethodNameError { + fn from(violation: SubjectTokenViolationError) -> Self { + match violation { + SubjectTokenViolationError::Empty => Self::Empty, + SubjectTokenViolationError::InvalidCharacter(ch) => Self::InvalidCharacter(ch), + SubjectTokenViolationError::TooLong(len) => Self::TooLong(len), + } + } +} + +/// A protobuf `rpc` method name that is safe to use as a subject token and as +/// a NATS micro endpoint name. +/// +/// Constrained to the protobuf identifier grammar, which is a subset of the +/// name charset NATS Services (ADR-32) accepts, so one construction satisfies +/// the proto contract and the endpoint registration together. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MethodName(NatsToken); + +impl MethodName { + pub fn from_input(input: &MethodNameInput) -> Result { + let value = input.as_str(); + let token = NatsToken::new(value)?; + + let mut characters = value.chars(); + let leading = characters.next().ok_or(MethodNameError::Empty)?; + if !leading.is_ascii_alphabetic() && leading != '_' { + return Err(MethodNameError::LeadingCharacter(leading)); + } + if let Some(ch) = characters.find(|ch| !ch.is_ascii_alphanumeric() && *ch != '_') { + return Err(MethodNameError::InvalidCharacter(ch)); + } + + Ok(Self(token)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for MethodName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs new file mode 100644 index 0000000000..c955c5738a --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs @@ -0,0 +1,53 @@ +use super::{MethodName, MethodNameError}; +use crate::method_name_input::MethodNameInput; + +#[test] +fn accepts_a_protobuf_method_name() { + let method = MethodName::from_input(&MethodNameInput::new("Say")).expect("protobuf method name is valid"); + assert_eq!(method.as_str(), "Say"); +} + +#[test] +fn rejects_empty() { + assert_eq!( + MethodName::from_input(&MethodNameInput::new("")), + Err(MethodNameError::Empty) + ); +} + +#[test] +fn rejects_a_leading_digit() { + assert_eq!( + MethodName::from_input(&MethodNameInput::new("2Say")), + Err(MethodNameError::LeadingCharacter('2')) + ); +} + +#[test] +fn rejects_subject_separators_and_wildcards() { + assert_eq!( + MethodName::from_input(&MethodNameInput::new("Say.Again")), + Err(MethodNameError::InvalidCharacter('.')) + ); + assert_eq!( + MethodName::from_input(&MethodNameInput::new("Say>")), + Err(MethodNameError::InvalidCharacter('>')) + ); +} + +#[test] +fn rejects_characters_outside_the_protobuf_identifier_grammar() { + assert_eq!( + MethodName::from_input(&MethodNameInput::new("Say-Again")), + Err(MethodNameError::InvalidCharacter('-')) + ); +} + +#[test] +fn rejects_a_name_over_the_subject_token_budget() { + let long = "S".repeat(129); + assert_eq!( + MethodName::from_input(&MethodNameInput::new(long.as_str())), + Err(MethodNameError::TooLong(129)) + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs new file mode 100644 index 0000000000..14596d930b --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs @@ -0,0 +1,19 @@ +//! An `rpc` method's name exactly as the protobuf descriptor spelled it +//! (ADR 0016 §2). + +/// Untrusted method name text. Carries no guarantee that the value is a legal +/// protobuf identifier, a legal subject token, or a legal micro endpoint name; +/// [`crate::MethodName::from_input`] is the single conversion into the domain +/// value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MethodNameInput(Box); + +impl MethodNameInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs new file mode 100644 index 0000000000..ba0097811e --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -0,0 +1,252 @@ +//! Registers a [`ServiceBinding`] as a NATS micro service (ADR 0016 §1, §5): +//! discovery, versioning, and per-endpoint stats come from `async_nats`'s +//! `service` feature. Only the error reply path (§3) bypasses micro's own +//! `respond`, because micro's error-respond helper cannot carry a body (see +//! [`reply_error`]). + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use async_nats::service::ServiceExt as _; +use async_nats::{Client, HeaderMap}; +use futures_util::StreamExt as _; +use thiserror::Error; +use trogon_nats::PublishClient; +use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; + +use crate::binding::ServiceBinding; +use crate::constants::HEADER_CONTENT_TYPE; +use crate::content_type::{ContentType, EncodeError}; +use crate::content_type_input::ContentTypeInput; +use crate::service_fault::ServiceFault; +use crate::status_codec::{self, Outcome}; + +/// Decodes a request payload and produces a reply payload for one endpoint. +/// +/// The handler receives the request bytes already isolated from NATS +/// transport concerns and returns the success reply body pre-encoded in +/// `content_type`, or a [`ServiceFault`] to report on the micro error +/// channel. +/// Pre-encoding the success body here (rather than a typed message) keeps +/// this trait's signature independent of any one request/response message +/// pair, so one registration loop can dispatch to endpoints with unrelated +/// message types. The trait is boxed-future based (rather than `impl +/// Future`) so `Box` values with different concrete +/// request/response types can share one `Vec` in [`serve`]. +pub trait EndpointHandler: Send + Sync { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + content_type: ContentType, + ) -> Pin, ServiceFault>> + Send + 'a>>; +} + +#[derive(Debug, Error)] +pub enum ServeError { + #[error("binding declares {endpoints} endpoints but {handlers} handlers were supplied")] + HandlerCount { endpoints: usize, handlers: usize }, + #[error("failed to start NATS micro service: {0}")] + Start(#[source] async_nats::Error), + #[error("failed to register endpoint {subject}: {source}")] + Endpoint { + subject: String, + #[source] + source: async_nats::Error, + }, +} + +/// Serve a [`ServiceBinding`] as a NATS micro service, dispatching each +/// endpoint's requests to its handler on a dedicated task until the returned +/// [`async_nats::service::Service`] is stopped or dropped. +/// +/// `content_type_policy` is the service's `ServiceOptions.content_type` +/// restriction (ADR 0016 §4); every endpoint negotiates against the same +/// policy. `handlers` must have exactly one entry per +/// `binding.endpoints()`, in the same order. +pub async fn serve( + client: &Client, + binding: &ServiceBinding, + content_type_policy: ServiceOptions, + handlers: Vec>, +) -> Result { + if binding.endpoints().len() != handlers.len() { + return Err(ServeError::HandlerCount { + endpoints: binding.endpoints().len(), + handlers: handlers.len(), + }); + } + + let mut builder = client.service_builder(); + if let Some(description) = binding.description() { + builder = builder.description(description); + } + // Only when there is something to report: micro omits the field entirely + // when it is unset, so setting an empty map would publish `metadata: {}` + // into every discovery record that declares none. + if !binding.metadata().is_empty() { + builder = builder.metadata(binding.metadata().entries().clone()); + } + let service = builder + .start(binding.name().as_str(), binding.version().as_str()) + .await + .map_err(ServeError::Start)?; + + let content_type_policy = Arc::new(content_type_policy); + for (endpoint, handler) in binding.endpoints().iter().zip(handlers) { + let subject = endpoint.subject().as_str().to_string(); + // Name the endpoint after the rpc method (ADR 0016 §2). Micro + // otherwise derives the name from the full subject, so `$SRV.INFO` + // and `$SRV.STATS` would report the dotted subject instead of the + // method the binding declared. + let mut endpoint_builder = service.endpoint_builder().name(endpoint.method_name().as_str()); + if !endpoint.metadata().is_empty() { + endpoint_builder = endpoint_builder.metadata(endpoint.metadata().entries().clone()); + } + let registration = endpoint_builder.add(subject.clone()).await; + let mut micro_endpoint = registration.map_err(|source| ServeError::Endpoint { subject, source })?; + + let client = client.clone(); + let content_type_policy = content_type_policy.clone(); + tokio::spawn(async move { + while let Some(request) = micro_endpoint.next().await { + dispatch(&client, &request, &content_type_policy, handler.as_ref()).await; + } + }); + } + + Ok(service) +} + +async fn dispatch( + client: &P, + request: &async_nats::service::Request, + content_type_policy: &ServiceOptions, + handler: &dyn EndpointHandler, +) { + let (content_type, outcome) = resolve( + request.message.headers.as_ref(), + &request.message.payload, + content_type_policy, + handler, + ) + .await; + + match outcome { + Ok(body) => reply_success(request, body, content_type).await, + Err(fault) => { + let reply = request.message.reply.clone(); + let published = reply_error(client, reply, fault, content_type).await; + let _ = published.inspect_err(warn_unencodable); + } + } +} + +/// Negotiate the request's encoding (ADR 0016 §4), run the handler, and report +/// the encoding the reply must use either way. +/// +/// A caller the policy turns away still has to be able to read why, so the +/// rejection is reported in the encoding the caller asked for; an encoding this +/// binding does not speak leaves protobuf as the only choice. +async fn resolve( + headers: Option<&HeaderMap>, + payload: &[u8], + content_type_policy: &ServiceOptions, + handler: &dyn EndpointHandler, +) -> (ContentType, Result, ServiceFault>) { + let requested = headers + .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) + .map(|value| ContentTypeInput::new(value.as_str())); + + match ContentType::negotiate(content_type_policy, requested.as_ref()) { + Ok(content_type) => { + let outcome = handler.handle(payload, content_type).await; + (content_type, outcome) + } + Err(error) => { + let rejection_content_type = requested + .as_ref() + .and_then(ContentType::from_input) + .unwrap_or(ContentType::Protobuf); + ( + rejection_content_type, + Err(ServiceFault::invalid_argument(error.to_string())), + ) + } + } +} + +async fn reply_success(request: &async_nats::service::Request, body: Vec, content_type: ContentType) { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONTENT_TYPE, content_type.header_value()); + let published = request + .respond_with_headers(Ok(bytes::Bytes::from(body)), headers) + .await; + warn_if_undelivered(published, ReplyKind::Success); +} + +/// Publish an error reply directly on the client, bypassing +/// [`async_nats::service::Request::respond_with_headers`]. +/// +/// `respond_with_headers` always publishes an empty body on `Err(..)` +/// (`async-nats` 0.49.1 `service/mod.rs`), which cannot satisfy ADR 0016 §3: +/// the error reply body must be one complete `google.rpc.Status`. Publishing +/// directly is the only way to set both the error headers and a non-empty +/// body. The trade-off is that this bypasses micro's own `num_errors` / +/// `last_error` endpoint statistics bookkeeping, which only `respond`/ +/// `respond_with_headers` update; ADR 0016 treats stats-counting as a +/// convenience micro provides, not an invariant, so body-completeness wins. +async fn reply_error( + client: &P, + reply: Option, + fault: ServiceFault, + content_type: ContentType, +) -> Result<(), EncodeError> { + let Some(reply) = reply else { + tracing::warn!("grpc-nats-micro: request had no reply subject; dropping error reply"); + return Ok(()); + }; + let encoded = status_codec::encode_reply(Outcome::Error(fault), content_type)?; + let mut headers = encoded.headers; + headers.insert(HEADER_CONTENT_TYPE, content_type.header_value()); + let published = client.publish_with_headers(reply, headers, encoded.body).await; + warn_if_undelivered(published, ReplyKind::Error); + Ok(()) +} + +/// A `Status` that will not encode leaves nothing to report: ADR 0016 §3 makes +/// the error body one complete `Status`, and half of one is not that. +fn warn_unencodable(error: &EncodeError) { + tracing::warn!(error = %error, "grpc-nats-micro: failed to encode error reply"); +} + +/// Which half of the reply contract a failed publish belongs to, so the log +/// line says what was lost without a second message per call site. +enum ReplyKind { + Success, + Error, +} + +impl ReplyKind { + const fn as_str(&self) -> &'static str { + match self { + Self::Success => "success", + Self::Error => "error", + } + } +} + +/// A reply that cannot be delivered has nowhere left to go: micro offers no +/// redelivery channel, and the caller learns of it by timing out. +fn warn_if_undelivered(published: Result<(), E>, reply_kind: ReplyKind) +where + E: std::fmt::Display, +{ + if let Err(error) = published { + let reply_kind = reply_kind.as_str(); + tracing::warn!(error = %error, reply_kind, "grpc-nats-micro: failed to publish reply"); + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server/tests.rs new file mode 100644 index 0000000000..016f229845 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server/tests.rs @@ -0,0 +1,168 @@ +use std::future::Future; +use std::pin::Pin; + +use async_nats::{HeaderMap, Subject}; +use buffa::Enumeration as _; + +use trogon_nats::AdvancedMockNatsClient; +use trogonai_proto::google::rpc::Code; +use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, ServiceOptions}; + +use super::{EndpointHandler, ReplyKind, reply_error, resolve, warn_if_undelivered, warn_unencodable}; +use crate::constants::{HEADER_CONTENT_TYPE, HEADER_ERROR_CODE}; +use crate::content_type::{ContentType, EncodeError}; +use crate::service_fault::ServiceFault; + +const REPLY_SUBJECT: &str = "_INBOX.reply"; + +struct EchoHandler; + +impl EndpointHandler for EchoHandler { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + _content_type: ContentType, + ) -> Pin, ServiceFault>> + Send + 'a>> { + Box::pin(async move { Ok(request_bytes.to_vec()) }) + } +} + +struct FailingHandler; + +impl EndpointHandler for FailingHandler { + fn handle<'a>( + &'a self, + _request_bytes: &'a [u8], + _content_type: ContentType, + ) -> Pin, ServiceFault>> + Send + 'a>> { + Box::pin(async { Err(ServiceFault::internal("boom")) }) + } +} + +fn content_type_header(value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONTENT_TYPE, value); + headers +} + +fn protobuf_only() -> ServiceOptions { + ServiceOptions { + content_type: ProtoContentType::CONTENT_TYPE_PROTOBUF.into(), + ..Default::default() + } +} + +fn reply_subject() -> Subject { + Subject::from_static(REPLY_SUBJECT) +} + +#[tokio::test] +async fn the_handler_runs_under_the_negotiated_encoding() { + let headers = content_type_header(ContentType::Json.header_value()); + + let (content_type, outcome) = resolve(Some(&headers), b"payload", &ServiceOptions::default(), &EchoHandler).await; + + assert_eq!(content_type, ContentType::Json); + assert_eq!(outcome.expect("the handler succeeded"), b"payload".to_vec()); +} + +#[tokio::test] +async fn a_request_without_a_content_type_header_negotiates_the_policy_default() { + let (content_type, outcome) = resolve(None, b"payload", &ServiceOptions::default(), &FailingHandler).await; + + assert_eq!(content_type, ContentType::Protobuf); + assert_eq!(outcome.expect_err("the handler failed").code().code(), Code::INTERNAL); +} + +#[tokio::test] +async fn a_rejected_encoding_is_reported_in_the_encoding_the_caller_asked_for() { + let headers = content_type_header(ContentType::Json.header_value()); + + let (content_type, outcome) = resolve(Some(&headers), b"payload", &protobuf_only(), &EchoHandler).await; + + assert_eq!(content_type, ContentType::Json); + assert_eq!( + outcome.expect_err("a json caller is turned away").code().code(), + Code::INVALID_ARGUMENT + ); +} + +#[tokio::test] +async fn an_encoding_the_binding_does_not_speak_falls_back_to_protobuf() { + let headers = content_type_header("application/xml"); + + let (content_type, outcome) = resolve(Some(&headers), b"payload", &ServiceOptions::default(), &EchoHandler).await; + + assert_eq!(content_type, ContentType::Protobuf); + assert_eq!( + outcome.expect_err("an unknown encoding is turned away").code().code(), + Code::INVALID_ARGUMENT + ); +} + +#[tokio::test] +async fn an_error_reply_is_published_on_the_reply_subject() { + let client = AdvancedMockNatsClient::new(); + + reply_error( + &client, + Some(reply_subject()), + ServiceFault::internal("boom"), + ContentType::Protobuf, + ) + .await + .expect("an encodable status"); + + assert_eq!(client.published_messages(), vec![REPLY_SUBJECT.to_string()]); + let headers = client.published_headers(); + let headers = headers.first().expect("the error reply carries headers"); + assert_eq!( + headers.get(HEADER_ERROR_CODE).expect("error code header").as_str(), + Code::INTERNAL.to_i32().to_string() + ); + assert_eq!( + headers.get(HEADER_CONTENT_TYPE).expect("content type header").as_str(), + ContentType::Protobuf.header_value() + ); +} + +#[tokio::test] +async fn a_request_without_a_reply_subject_drops_the_error_reply() { + let client = AdvancedMockNatsClient::new(); + + reply_error(&client, None, ServiceFault::internal("boom"), ContentType::Protobuf) + .await + .expect("an encodable status"); + + assert!(client.published_messages().is_empty()); +} + +#[tokio::test] +async fn a_publish_failure_leaves_the_error_reply_undelivered() { + let client = AdvancedMockNatsClient::new(); + client.fail_next_publish(); + + reply_error( + &client, + Some(reply_subject()), + ServiceFault::internal("boom"), + ContentType::Protobuf, + ) + .await + .expect("an encodable status"); + + assert!(client.published_messages().is_empty()); +} + +#[test] +fn an_undelivered_reply_is_reported_for_either_half_of_the_contract() { + warn_if_undelivered(Err(std::io::Error::other("gone")), ReplyKind::Success); + warn_if_undelivered(Err(std::io::Error::other("gone")), ReplyKind::Error); + warn_if_undelivered(Ok::<(), std::io::Error>(()), ReplyKind::Success); +} + +#[test] +fn a_status_that_will_not_encode_is_reported() { + let source = serde_json::from_str::("not a number").expect_err("a malformed number"); + warn_unencodable(&EncodeError::Json(source)); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code.rs new file mode 100644 index 0000000000..6c52950008 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code.rs @@ -0,0 +1,64 @@ +//! The `google.rpc.Code` of an error reply: known, and never `OK`. + +use buffa::Enumeration as _; +use thiserror::Error; +use trogonai_proto::google::rpc::Code; + +use crate::service_error_code_input::ServiceErrorCodeInput; + +/// Why a wire value cannot describe a service error. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ServiceErrorCodeError { + #[error("service error code {header} is not an integer")] + NotAnInteger { header: ServiceErrorCodeInput }, + #[error("service error code {value} is not a google.rpc.Code")] + UnknownCode { value: i32 }, + #[error("google.rpc.Code OK cannot describe a service error")] + OkCode, +} + +/// A `google.rpc.Code` an error reply may carry. ADR 0016 §3 makes the error +/// channel's code space exclude `OK`, so an `OK`-coded fault is not +/// representable rather than repaired downstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServiceErrorCode(Code); + +impl ServiceErrorCode { + /// The codes this binding raises itself, which the transport needs + /// without a fallible construction step. + pub const INTERNAL: Self = Self(Code::INTERNAL); + pub const INVALID_ARGUMENT: Self = Self(Code::INVALID_ARGUMENT); + + pub fn new(value: i32) -> Result { + let code = Code::from_i32(value).ok_or(ServiceErrorCodeError::UnknownCode { value })?; + if code == Code::OK { + return Err(ServiceErrorCodeError::OkCode); + } + Ok(Self(code)) + } + + pub fn from_input(input: &ServiceErrorCodeInput) -> Result { + let value: i32 = input + .as_str() + .parse() + .map_err(|_| ServiceErrorCodeError::NotAnInteger { header: input.clone() })?; + Self::new(value) + } + + pub const fn code(self) -> Code { + self.0 + } + + pub fn to_i32(self) -> i32 { + self.0.to_i32() + } +} + +impl std::fmt::Display for ServiceErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs new file mode 100644 index 0000000000..d425e2838c --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs @@ -0,0 +1,46 @@ +use super::{ServiceErrorCode, ServiceErrorCodeError}; +use crate::service_error_code_input::ServiceErrorCodeInput; +use buffa::Enumeration as _; +use trogonai_proto::google::rpc::Code; + +#[test] +fn accepts_a_known_fault_code() { + let code = ServiceErrorCode::new(Code::RESOURCE_EXHAUSTED.to_i32()).expect("a known non-OK code"); + assert_eq!(code.code(), Code::RESOURCE_EXHAUSTED); +} + +#[test] +fn rejects_ok() { + assert_eq!( + ServiceErrorCode::new(Code::OK.to_i32()), + Err(ServiceErrorCodeError::OkCode) + ); +} + +#[test] +fn rejects_a_code_outside_the_enum() { + assert_eq!( + ServiceErrorCode::new(4242), + Err(ServiceErrorCodeError::UnknownCode { value: 4242 }) + ); +} + +#[test] +fn rejects_a_header_that_is_not_an_integer() { + let header = ServiceErrorCodeInput::new("RESOURCE_EXHAUSTED"); + assert_eq!( + ServiceErrorCode::from_input(&header), + Err(ServiceErrorCodeError::NotAnInteger { header: header.clone() }) + ); + assert_eq!( + ServiceErrorCodeError::NotAnInteger { header }.to_string(), + "service error code RESOURCE_EXHAUSTED is not an integer" + ); +} + +#[test] +fn reads_a_well_formed_header() { + let header = ServiceErrorCodeInput::new(Code::NOT_FOUND.to_i32().to_string()); + let code = ServiceErrorCode::from_input(&header).expect("a well formed header"); + assert_eq!(code.code(), Code::NOT_FOUND); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code_input.rs new file mode 100644 index 0000000000..eeeea3ff27 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code_input.rs @@ -0,0 +1,25 @@ +//! The `Nats-Service-Error-Code` header exactly as a responder sent it +//! (ADR 0016 §3). + +/// Untrusted service error code header text. Carries no guarantee that the +/// value is an integer, a known `google.rpc.Code`, or a code that may appear +/// on an error reply; [`crate::ServiceErrorCode::from_input`] is the single +/// conversion into the domain value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceErrorCodeInput(Box); + +impl ServiceErrorCodeInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ServiceErrorCodeInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault.rs new file mode 100644 index 0000000000..b3b167a8e1 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault.rs @@ -0,0 +1,67 @@ +//! A fault an endpoint reports on the micro error channel (ADR 0016 §3). + +use trogonai_proto::google::rpc::Status; + +use crate::service_error_code::{ServiceErrorCode, ServiceErrorCodeError}; + +/// A `google.rpc.Status` whose code is a valid service error code, so an +/// error reply cannot be emitted without one. `details` travels with it +/// because ADR 0016 §3 makes the body the only place `details` is readable. +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceFault { + code: ServiceErrorCode, + status: Status, +} + +impl ServiceFault { + pub fn new(status: Status) -> Result { + let code = ServiceErrorCode::new(status.code)?; + Ok(Self { code, status }) + } + + /// Build a fault from a body and the code the transport says is + /// authoritative, which ADR 0016 §3 makes the `Nats-Service-Error-Code` + /// header on disagreement with the body. + pub fn with_code(code: ServiceErrorCode, mut status: Status) -> Self { + status.code = code.to_i32(); + Self { code, status } + } + + pub fn invalid_argument(message: impl Into) -> Self { + Self::of(ServiceErrorCode::INVALID_ARGUMENT, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::of(ServiceErrorCode::INTERNAL, message) + } + + fn of(code: ServiceErrorCode, message: impl Into) -> Self { + Self { + code, + status: Status { + code: code.to_i32(), + message: message.into(), + details: Vec::new(), + }, + } + } + + pub const fn code(&self) -> ServiceErrorCode { + self.code + } + + pub fn message(&self) -> &str { + &self.status.message + } + + pub fn status(&self) -> &Status { + &self.status + } + + pub fn into_status(self) -> Status { + self.status + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs new file mode 100644 index 0000000000..2ca977c5a7 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs @@ -0,0 +1,48 @@ +use super::ServiceFault; +use crate::service_error_code::{ServiceErrorCode, ServiceErrorCodeError}; +use buffa::Enumeration as _; +use trogonai_proto::google::rpc::{Code, Status}; + +#[test] +fn rejects_an_ok_coded_status() { + let status = Status { + code: Code::OK.to_i32(), + message: "not a fault".to_string(), + details: Vec::new(), + }; + assert_eq!(ServiceFault::new(status), Err(ServiceErrorCodeError::OkCode)); +} + +#[test] +fn the_authoritative_code_overrides_the_body() { + let status = Status { + code: Code::UNKNOWN.to_i32(), + message: "out of quota".to_string(), + details: Vec::new(), + }; + let code = ServiceErrorCode::new(Code::RESOURCE_EXHAUSTED.to_i32()).expect("a known non-OK code"); + + let fault = ServiceFault::with_code(code, status); + + assert_eq!(fault.code(), code); + assert_eq!(fault.status().code, Code::RESOURCE_EXHAUSTED.to_i32()); +} + +#[test] +fn named_constructors_carry_their_code() { + assert_eq!( + ServiceFault::invalid_argument("bad").code().code(), + Code::INVALID_ARGUMENT + ); + assert_eq!(ServiceFault::internal("boom").code().code(), Code::INTERNAL); +} + +#[test] +fn hands_over_the_whole_status_it_carries() { + let fault = ServiceFault::internal("boom"); + + let status = fault.into_status(); + + assert_eq!(status.code, Code::INTERNAL.to_i32()); + assert_eq!(status.message, "boom"); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs new file mode 100644 index 0000000000..8037df631c --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs @@ -0,0 +1,69 @@ +//! The annotated protobuf `service`'s name, which is both a subject token and +//! the registered NATS micro service's name (ADR 0016 §1, §2). + +use trogon_nats::{NatsToken, SubjectTokenViolationError}; + +use crate::service_name_input::ServiceNameInput; + +/// Why a [`ServiceName`] could not be constructed. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ServiceNameError { + #[error("service name must not be empty")] + Empty, + #[error("service name must start with an ASCII letter or underscore, found {0:?}")] + LeadingCharacter(char), + #[error("service name contains invalid character: {0:?}")] + InvalidCharacter(char), + #[error("service name is too long: {0} characters")] + TooLong(usize), +} + +impl From for ServiceNameError { + fn from(violation: SubjectTokenViolationError) -> Self { + match violation { + SubjectTokenViolationError::Empty => Self::Empty, + SubjectTokenViolationError::InvalidCharacter(ch) => Self::InvalidCharacter(ch), + SubjectTokenViolationError::TooLong(len) => Self::TooLong(len), + } + } +} + +/// A protobuf service name that is safe to use as both a subject token and a +/// NATS micro service name. +/// +/// Constrained to the protobuf identifier grammar, which is a subset of the +/// name charset NATS Services (ADR-32) accepts, so one construction satisfies +/// the proto contract and the micro registration together. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ServiceName(NatsToken); + +impl ServiceName { + pub fn from_input(input: &ServiceNameInput) -> Result { + let value = input.as_str(); + let token = NatsToken::new(value)?; + + let mut characters = value.chars(); + let leading = characters.next().ok_or(ServiceNameError::Empty)?; + if !leading.is_ascii_alphabetic() && leading != '_' { + return Err(ServiceNameError::LeadingCharacter(leading)); + } + if let Some(ch) = characters.find(|ch| !ch.is_ascii_alphanumeric() && *ch != '_') { + return Err(ServiceNameError::InvalidCharacter(ch)); + } + + Ok(Self(token)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for ServiceName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs new file mode 100644 index 0000000000..ff32ffdbbe --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs @@ -0,0 +1,53 @@ +use super::{ServiceName, ServiceNameError}; +use crate::service_name_input::ServiceNameInput; + +#[test] +fn accepts_a_protobuf_service_name() { + let name = ServiceName::from_input(&ServiceNameInput::new("EchoService")).expect("protobuf service name is valid"); + assert_eq!(name.as_str(), "EchoService"); +} + +#[test] +fn rejects_empty() { + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("")), + Err(ServiceNameError::Empty) + ); +} + +#[test] +fn rejects_a_leading_digit() { + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("1Echo")), + Err(ServiceNameError::LeadingCharacter('1')) + ); +} + +#[test] +fn rejects_subject_separators_and_wildcards() { + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("echo.v1")), + Err(ServiceNameError::InvalidCharacter('.')) + ); + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("Echo*")), + Err(ServiceNameError::InvalidCharacter('*')) + ); +} + +#[test] +fn rejects_characters_outside_the_protobuf_identifier_grammar() { + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("Echo-Service")), + Err(ServiceNameError::InvalidCharacter('-')) + ); +} + +#[test] +fn rejects_a_name_over_the_subject_token_budget() { + let long = "E".repeat(129); + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new(long.as_str())), + Err(ServiceNameError::TooLong(129)) + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs new file mode 100644 index 0000000000..840f89419a --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs @@ -0,0 +1,19 @@ +//! An annotated `service`'s name exactly as the protobuf descriptor spelled it +//! (ADR 0016 §1). + +/// Untrusted service name text. Carries no guarantee that the value is a legal +/// protobuf identifier, a legal subject token, or a legal micro service name; +/// [`crate::ServiceName::from_input`] is the single conversion into the domain +/// value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceNameInput(Box); + +impl ServiceNameInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs new file mode 100644 index 0000000000..34cef65e61 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs @@ -0,0 +1,38 @@ +//! The registered NATS micro service's version (ADR 0016 §1). + +use crate::service_version_input::ServiceVersionInput; + +/// Why a [`ServiceVersion`] could not be constructed. +#[derive(Debug, thiserror::Error)] +#[error("service version is not a semantic version")] +pub struct ServiceVersionError(#[from] semver::Error); + +/// A service version NATS micro will accept. +/// +/// NATS Services (ADR-32) admits only semantic versions, and `async_nats` +/// rejects anything else when the service starts. Parsing at construction +/// moves that rejection off the startup path, so a binding that exists is one +/// that can register. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ServiceVersion(Box); + +impl ServiceVersion { + pub fn from_input(input: &ServiceVersionInput) -> Result { + let value = input.as_str(); + semver::Version::parse(value)?; + Ok(Self(value.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ServiceVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs new file mode 100644 index 0000000000..300735cc34 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs @@ -0,0 +1,23 @@ +use super::ServiceVersion; +use crate::service_version_input::ServiceVersionInput; + +#[test] +fn accepts_a_semantic_version() { + let version = ServiceVersion::from_input(&ServiceVersionInput::new("1.0.0")).expect("a semantic version"); + assert_eq!(version.as_str(), "1.0.0"); +} + +#[test] +fn accepts_a_prerelease_and_build_version() { + let version = + ServiceVersion::from_input(&ServiceVersionInput::new("1.0.0-rc.1+build.7")).expect("a semantic version"); + assert_eq!(version.to_string(), "1.0.0-rc.1+build.7"); +} + +/// NATS micro rejects a bare major at startup, so the binding rejects it first. +#[test] +fn rejects_a_version_that_is_not_semantic() { + let error = + ServiceVersion::from_input(&ServiceVersionInput::new("1")).expect_err("a bare major is not a semantic version"); + assert_eq!(error.to_string(), "service version is not a semantic version"); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs new file mode 100644 index 0000000000..03252f9082 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs @@ -0,0 +1,20 @@ +//! The service version exactly as the `service` annotation spelled it +//! (ADR 0016 §1). + +/// Untrusted service version text, as it arrives in +/// `trogon.nats.micro.v1alpha1.ServiceOptions.version`. Carries no guarantee +/// that the value is a semantic version, which is the only shape NATS Services +/// admits; [`crate::ServiceVersion::from_input`] is the single conversion into +/// the domain value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceVersionInput(Box); + +impl ServiceVersionInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs new file mode 100644 index 0000000000..31ab407289 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -0,0 +1,159 @@ +//! The micro error channel (ADR 0016 §3): a reply is an error iff +//! `Nats-Service-Error-Code` is present, and on error the body is one +//! complete `google.rpc.Status` encoded per the negotiated [`ContentType`]. + +use std::str::FromStr as _; + +use async_nats::{HeaderMap, HeaderValue}; +use bytes::Bytes; +use thiserror::Error; +use trogonai_proto::google::rpc::Status; + +use crate::constants::{HEADER_CONTENT_TYPE, HEADER_ERROR, HEADER_ERROR_CODE}; +use crate::content_type::{ContentType, DecodeError, EncodeError}; +use crate::content_type_input::ContentTypeInput; +use crate::service_error_code::{ServiceErrorCode, ServiceErrorCodeError}; +use crate::service_error_code_input::ServiceErrorCodeInput; +use crate::service_fault::ServiceFault; + +/// A successful reply body, or a fault reported on the micro error channel. +pub enum Outcome { + Success(Bytes), + Error(ServiceFault), +} + +/// Headers and body ready to publish as a NATS reply. +pub struct EncodedReply { + pub headers: HeaderMap, + pub body: Bytes, +} + +/// Server-side: encode an [`Outcome`] into the headers and body a NATS reply +/// needs, per ADR 0016 §3. +pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result { + match outcome { + Outcome::Success(body) => Ok(EncodedReply { + headers: HeaderMap::new(), + body, + }), + Outcome::Error(fault) => { + let body = content_type.encode(fault.status())?; + let mut headers = HeaderMap::new(); + if let Some(message) = describe(fault.message()) { + headers.insert(HEADER_ERROR, message); + } + headers.insert(HEADER_ERROR_CODE, fault.code().to_i32().to_string().as_str()); + Ok(EncodedReply { + headers, + body: Bytes::from(body), + }) + } + } +} + +/// The fault message as a [`HEADER_ERROR`] value, or `None` when it cannot be +/// one. +/// +/// NATS header values may not contain CR or LF, and `HeaderValue`'s `From<&str>` +/// asserts that rather than reporting it, so a fault whose message spans lines +/// would abort the dispatch task mid-reply. +/// +/// Omitting the header is safe because ADR 0016 §3 puts the authoritative +/// message in the body's complete `google.rpc.Status`, and makes +/// [`HEADER_ERROR_CODE`], not this header, the thing that marks a reply as an +/// error. Failing the reply instead would trade a panic for a caller timeout +/// and lose a `Status` that encoded perfectly well. +fn describe(message: &str) -> Option { + HeaderValue::from_str(message) + .inspect_err(|error| { + tracing::warn!( + error = %error, + header = HEADER_ERROR, + "grpc-nats-micro: fault message is not a valid header value; \ + replying with the status body alone" + ); + }) + .ok() +} + +/// A decoded micro service error: the whole `google.rpc.Status` from the reply +/// body, so `details` (`ErrorInfo`, `BadRequest`, `RetryInfo`, ...) reaches the +/// caller, since ADR 0016 §3 makes the body the only place `details` is +/// readable. +#[derive(Debug, Clone, PartialEq, Error)] +#[error("nats micro service error ({}): {}", .fault.code(), .fault.message())] +pub struct ServiceError { + fault: ServiceFault, +} + +impl ServiceError { + pub const fn code(&self) -> ServiceErrorCode { + self.fault.code() + } + + pub fn message(&self) -> &str { + self.fault.message() + } + + pub fn status(&self) -> &Status { + self.fault.status() + } + + pub fn into_status(self) -> Status { + self.fault.into_status() + } +} + +/// Client-side: decode a raw NATS reply per the ADR 0016 §3 error-channel rule. +/// +/// A reply is an error iff [`HEADER_ERROR_CODE`] is present; the header value +/// is the canonical `google.rpc.Code`, authoritative over the body's `code` field on +/// disagreement, and the body is decoded as the complete [`Status`]. Absent +/// the header, the body is decoded as `Resp`. +/// +/// `requested` is only a fallback for a reply that declares no `Content-Type`: +/// ADR 0016 §4 makes the reply's own `Content-Type` authoritative for how its +/// body is encoded, which is what lets a rejection of the requested encoding +/// still be readable. A reply that declares an encoding this binding does not +/// speak is reported rather than decoded, since falling back to `requested` +/// there would decode the body as something the sender never wrote. +pub fn decode_reply(headers: Option<&HeaderMap>, body: &[u8], requested: ContentType) -> Result +where + Resp: buffa::Message + serde::de::DeserializeOwned, +{ + let declared = headers + .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) + .map(|value| ContentTypeInput::new(value.as_str())); + let content_type = match declared { + Some(declared) => ContentType::from_input(&declared).ok_or(ReplyError::ContentType { declared })?, + None => requested, + }; + let error_code = headers.and_then(|headers| headers.get(HEADER_ERROR_CODE)); + + match error_code { + Some(code_header) => { + let input = ServiceErrorCodeInput::new(code_header.as_str()); + let code = ServiceErrorCode::from_input(&input).map_err(ReplyError::ErrorCode)?; + let status: Status = content_type.decode(body).map_err(ReplyError::Decode)?; + Err(ReplyError::Service(ServiceError { + fault: ServiceFault::with_code(code, status), + })) + } + None => content_type.decode(body).map_err(ReplyError::Decode), + } +} + +#[derive(Debug, Error)] +pub enum ReplyError { + #[error("invalid {HEADER_ERROR_CODE} header")] + ErrorCode(#[source] ServiceErrorCodeError), + #[error("reply declares an unsupported Content-Type: {declared}")] + ContentType { declared: ContentTypeInput }, + #[error("failed to decode reply payload")] + Decode(#[source] DecodeError), + #[error(transparent)] + Service(#[from] ServiceError), +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs new file mode 100644 index 0000000000..f802112627 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs @@ -0,0 +1,175 @@ +use async_nats::HeaderMap; +use buffa::Enumeration as _; +use bytes::Bytes; +use trogonai_proto::google::rpc::{Code, Status}; +use trogonai_proto::grpc_nats_micro::v1::SayResponse; + +use super::{Outcome, ReplyError, decode_reply, encode_reply}; +use crate::constants::{HEADER_CONTENT_TYPE, HEADER_ERROR, HEADER_ERROR_CODE}; +use crate::content_type::ContentType; +use crate::service_fault::ServiceFault; + +fn status(code: Code, message: &str) -> Status { + Status { + code: code.to_i32(), + message: message.to_string(), + details: Vec::new(), + } +} + +#[test] +fn a_success_reply_carries_no_error_headers() { + let encoded = + encode_reply(Outcome::Success(Bytes::from_static(b"body")), ContentType::Protobuf).expect("a success reply"); + + assert!(encoded.headers.get(HEADER_ERROR_CODE).is_none()); + assert!(encoded.headers.get(HEADER_ERROR).is_none()); + assert_eq!(encoded.body, Bytes::from_static(b"body")); +} + +#[test] +fn an_error_reply_carries_the_code_and_message_headers() { + let encoded = + encode_reply(Outcome::Error(ServiceFault::internal("boom")), ContentType::Json).expect("an error reply"); + + assert_eq!( + encoded + .headers + .get(HEADER_ERROR_CODE) + .expect("error code header") + .as_str(), + Code::INTERNAL.to_i32().to_string() + ); + assert_eq!( + encoded + .headers + .get(HEADER_ERROR) + .expect("error message header") + .as_str(), + "boom" + ); +} + +/// A multi-line fault message cannot be a NATS header value. The reply still +/// has to carry the fault, so the code header and the complete `Status` body +/// stay and only the descriptive header is dropped (ADR 0016 §3). +#[test] +fn a_multi_line_fault_message_still_replies_with_the_status_body() { + let message = "boom\r\nsecond line"; + + let encoded = + encode_reply(Outcome::Error(ServiceFault::internal(message)), ContentType::Json).expect("an error reply"); + + assert!(encoded.headers.get(HEADER_ERROR).is_none()); + assert_eq!( + encoded + .headers + .get(HEADER_ERROR_CODE) + .expect("error code header") + .as_str(), + Code::INTERNAL.to_i32().to_string() + ); + let decoded = decode_reply::(Some(&encoded.headers), &encoded.body, ContentType::Json) + .expect_err("an error reply decodes as an error"); + let ReplyError::Service(error) = decoded else { + panic!("expected a service error, got {decoded:?}"); + }; + assert_eq!(error.message(), message); +} + +#[test] +fn a_reply_without_the_error_header_decodes_as_the_response() { + let response = SayResponse { + message: Some("hello".to_string()), + }; + let body = ContentType::Protobuf.encode(&response).expect("encode SayResponse"); + + let decoded: SayResponse = decode_reply(None, &body, ContentType::Protobuf).expect("a success reply decodes"); + + assert_eq!(decoded.message, Some("hello".to_string())); +} + +/// ADR 0016 §4 makes the reply's own `Content-Type` authoritative, which is +/// what lets a rejection of the requested encoding still be readable. +#[test] +fn the_replys_own_content_type_overrides_the_requested_one() { + let response = SayResponse { + message: Some("hello".to_string()), + }; + let body = ContentType::Json.encode(&response).expect("encode SayResponse"); + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONTENT_TYPE, ContentType::Json.header_value()); + + let decoded: SayResponse = + decode_reply(Some(&headers), &body, ContentType::Protobuf).expect("the reply names its own encoding"); + + assert_eq!(decoded.message, Some("hello".to_string())); +} + +#[test] +fn an_error_code_header_that_is_not_a_code_is_reported() { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_ERROR_CODE, "RESOURCE_EXHAUSTED"); + + let error = + decode_reply::(Some(&headers), b"", ContentType::Protobuf).expect_err("the header is not a code"); + + let ReplyError::ErrorCode(cause) = error else { + panic!("expected an error code failure"); + }; + assert_eq!( + cause.to_string(), + "service error code RESOURCE_EXHAUSTED is not an integer" + ); +} + +#[test] +fn an_error_reply_surfaces_the_whole_status() { + let body = status(Code::NOT_FOUND, "missing"); + let mut headers = HeaderMap::new(); + headers.insert(HEADER_ERROR_CODE, Code::NOT_FOUND.to_i32().to_string().as_str()); + let payload = ContentType::Protobuf.encode(&body).expect("encode Status"); + + let error = + decode_reply::(Some(&headers), &payload, ContentType::Protobuf).expect_err("an error reply"); + + let ReplyError::Service(service_error) = error else { + panic!("expected a micro service error"); + }; + assert_eq!(service_error.code().code(), Code::NOT_FOUND); + assert_eq!(service_error.message(), "missing"); + assert_eq!(service_error.status(), &body); + assert_eq!( + service_error.to_string(), + "nats micro service error (NOT_FOUND): missing" + ); + assert_eq!(service_error.into_status(), body); +} + +#[test] +fn an_undecodable_error_body_is_reported() { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_ERROR_CODE, Code::NOT_FOUND.to_i32().to_string().as_str()); + + let error = decode_reply::(Some(&headers), b"not json", ContentType::Json) + .expect_err("the body is not a Status"); + + assert!(matches!(error, ReplyError::Decode(_))); +} + +/// ADR 0016 §4 makes the reply's own `Content-Type` authoritative, so an +/// encoding this binding does not speak is reported rather than decoded as +/// whatever the caller happened to request. +#[test] +fn a_reply_declaring_an_unsupported_content_type_is_reported() { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONTENT_TYPE, "application/xml"); + + let error = decode_reply::(Some(&headers), b"", ContentType::Protobuf) + .expect_err("the reply names an encoding this binding does not speak"); + + let ReplyError::ContentType { declared } = error else { + panic!("expected an unsupported content type failure"); + }; + assert_eq!(declared.as_str(), "application/xml"); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs new file mode 100644 index 0000000000..3b16904af4 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs @@ -0,0 +1,54 @@ +//! The NATS subject namespace one service's endpoints are derived under +//! (ADR 0016 §2). + +use trogon_nats::{DottedNatsToken, SubjectTokenViolationError}; + +use crate::subject_prefix_input::SubjectPrefixInput; + +/// Why a [`SubjectPrefix`] could not be constructed. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SubjectPrefixError { + #[error("subject prefix must not be empty")] + Empty, + #[error("subject prefix contains invalid character: {0:?}")] + InvalidCharacter(char), + #[error("subject prefix is too long: {0} bytes")] + TooLong(usize), +} + +impl From for SubjectPrefixError { + fn from(violation: SubjectTokenViolationError) -> Self { + match violation { + SubjectTokenViolationError::Empty => Self::Empty, + SubjectTokenViolationError::InvalidCharacter(ch) => Self::InvalidCharacter(ch), + SubjectTokenViolationError::TooLong(len) => Self::TooLong(len), + } + } +} + +/// The dotted namespace every endpoint subject of one service is derived under. +/// +/// Dotted, so a deployment can namespace by domain and binding version +/// (`echo.v1`). Wildcards and malformed dots are rejected here rather than at +/// registration, because the subject this prefix feeds is a concrete address. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubjectPrefix(DottedNatsToken); + +impl SubjectPrefix { + pub fn from_input(input: &SubjectPrefixInput) -> Result { + DottedNatsToken::new(input.as_str()).map(Self).map_err(Into::into) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for SubjectPrefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs new file mode 100644 index 0000000000..b6fa999138 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs @@ -0,0 +1,61 @@ +use super::{SubjectPrefix, SubjectPrefixError}; +use crate::subject_prefix_input::SubjectPrefixInput; + +#[test] +fn accepts_a_dotted_namespace() { + let prefix = SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.v1")).expect("dotted prefix is valid"); + assert_eq!(prefix.as_str(), "echo.v1"); +} + +#[test] +fn rejects_empty() { + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("")), + Err(SubjectPrefixError::Empty) + ); +} + +#[test] +fn rejects_wildcards() { + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.*")), + Err(SubjectPrefixError::InvalidCharacter('*')) + ); + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.>")), + Err(SubjectPrefixError::InvalidCharacter('>')) + ); +} + +#[test] +fn rejects_malformed_dots() { + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new(".echo")), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.")), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo..v1")), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); +} + +#[test] +fn rejects_whitespace() { + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo v1")), + Err(SubjectPrefixError::InvalidCharacter(' ')) + ); +} + +#[test] +fn rejects_a_prefix_over_the_subject_token_budget() { + let long = "e".repeat(129); + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new(long.as_str())), + Err(SubjectPrefixError::TooLong(129)) + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs new file mode 100644 index 0000000000..92f17973e8 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs @@ -0,0 +1,19 @@ +//! The configured subject namespace exactly as a deployment supplied it +//! (ADR 0016 §2). + +/// Untrusted subject prefix text. Carries no guarantee that the value is a +/// dotted run of legal subject tokens, or that it is free of the wildcards a +/// concrete address may not contain; [`crate::SubjectPrefix::from_input`] is +/// the single conversion into the domain value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectPrefixInput(Box); + +impl SubjectPrefixInput { + pub fn new(value: impl Into>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs new file mode 100644 index 0000000000..3f28e4a40a --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -0,0 +1,531 @@ +//! ADR 0016 conformance: an Echo/Fail service registered through +//! [`grpc_nats_micro::serve`] against a real NATS server in a container. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use async_nats::HeaderMap; +use async_nats::service::Service; +use buffa::Enumeration as _; +use buffa_types::google::protobuf::Any; +use grpc_nats_micro::client::RequestError; +use grpc_nats_micro::constants::HEADER_ERROR_CODE; +use grpc_nats_micro::status_codec::ReplyError; +use grpc_nats_micro::{ + ContentType, DiscoveryMetadata, DiscoveryMetadataInput, EndpointBinding, EndpointHandler, MethodName, + MethodNameInput, ServiceBinding, ServiceFault, ServiceName, ServiceNameInput, ServiceVersion, ServiceVersionInput, + SubjectPrefix, SubjectPrefixInput, +}; +use trogon_nats::test_support::CoreTestServer; +use trogon_nats::{NatsConfig, RequestClient}; +use trogonai_proto::google::rpc::{Code, ErrorInfo, Status}; +use trogonai_proto::grpc_nats_micro::v1::{FailRequest, FailResponse, SayRequest, SayResponse}; +use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, ServiceOptions}; + +const SUBJECT_PREFIX: &str = "echo.v1"; +const SERVICE_NAME: &str = "EchoService"; +const SERVICE_VERSION: &str = "1.0.0"; +const SERVICE_DESCRIPTION: &str = "Echoes what it is told"; +const SAY_METHOD: &str = "Say"; +const FAIL_METHOD: &str = "Fail"; +const SERVICE_METADATA_KEY: &str = "adr"; +const SERVICE_METADATA_VALUE: &str = "0016"; +const FAIL_METADATA_KEY: &str = "always-faults"; +const FAIL_METADATA_VALUE: &str = "true"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const STOPPED_SERVICE_TIMEOUT: Duration = Duration::from_millis(500); +const STOPPED_SERVICE_DEADLINE: Duration = Duration::from_secs(10); +const STOPPED_SERVICE_POLL: Duration = Duration::from_millis(25); +const FAIL_DETAIL_REASON: &str = "ECHO_FAIL"; +const FAIL_DETAIL_DOMAIN: &str = "grpc-nats-micro.conformance"; + +/// Connect the way a service would: through this workspace's own +/// [`trogon_nats::connect`], so the conformance run exercises the configured +/// connect path rather than a raw `async_nats` client. +async fn connect(server: &CoreTestServer) -> Result { + trogon_nats::connect(&NatsConfig::from_url(server.address()), CONNECT_TIMEOUT).await +} + +struct SayHandler; + +impl EndpointHandler for SayHandler { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + content_type: ContentType, + ) -> Pin, ServiceFault>> + Send + 'a>> { + Box::pin(async move { + let request: SayRequest = content_type + .decode(request_bytes) + .map_err(|error| ServiceFault::invalid_argument(error.to_string()))?; + let reply = SayResponse { + message: request.message, + }; + content_type + .encode(&reply) + .map_err(|error| ServiceFault::internal(error.to_string())) + }) + } +} + +struct FailHandler; + +impl EndpointHandler for FailHandler { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + content_type: ContentType, + ) -> Pin, ServiceFault>> + Send + 'a>> { + Box::pin(async move { + let request: FailRequest = content_type + .decode(request_bytes) + .map_err(|error| ServiceFault::invalid_argument(error.to_string()))?; + let code = request.code.and_then(|value| value.as_known()).unwrap_or(Code::UNKNOWN); + let detail = ErrorInfo { + reason: FAIL_DETAIL_REASON.to_string(), + domain: FAIL_DETAIL_DOMAIN.to_string(), + ..Default::default() + }; + Err(ServiceFault::new(Status { + code: code.to_i32(), + message: request.message.unwrap_or_default(), + details: vec![Any::pack(&detail, ErrorInfo::TYPE_URL)], + }) + .expect("FailRequest carries a service error code")) + }) + } +} + +fn echo_service_binding() -> ServiceBinding { + ServiceBinding::new( + ServiceName::from_input(&ServiceNameInput::new(SERVICE_NAME)).expect("valid service name"), + ServiceVersion::from_input(&ServiceVersionInput::new(SERVICE_VERSION)).expect("valid service version"), + SubjectPrefix::from_input(&SubjectPrefixInput::new(SUBJECT_PREFIX)).expect("valid subject prefix"), + ) + .with_description(SERVICE_DESCRIPTION) + .with_metadata(metadata([(SERVICE_METADATA_KEY, SERVICE_METADATA_VALUE)])) + .with_method(method(SAY_METHOD), DiscoveryMetadata::default()) + .expect("derive Say subject") + // Mirrors the `always-faults` annotation `Fail` carries in echo.proto. + .with_method( + method(FAIL_METHOD), + metadata([(FAIL_METADATA_KEY, FAIL_METADATA_VALUE)]), + ) + .expect("derive Fail subject") +} + +fn method(name: &str) -> MethodName { + MethodName::from_input(&MethodNameInput::new(name)).expect("valid method name") +} + +fn metadata(entries: [(&str, &str); N]) -> DiscoveryMetadata { + DiscoveryMetadata::from_input(&DiscoveryMetadataInput::new(entries)).expect("valid discovery metadata") +} + +/// Keeps the NATS container, the client, the service registration, and the +/// derived subject binding alive together: dropping the [`Service`] handle +/// closes its internal shutdown broadcast, which stops every endpoint task +/// started by [`grpc_nats_micro::serve`]. +struct EchoFixture { + _server: CoreTestServer, + client: async_nats::Client, + binding: ServiceBinding, + _service: Service, +} + +async fn start_fixture() -> EchoFixture { + start_fixture_with_policy(ServiceOptions::default()).await +} + +async fn start_fixture_with_policy(content_type_policy: ServiceOptions) -> EchoFixture { + let server = CoreTestServer::start().await; + let client = connect(&server).await.expect("connect to the NATS testcontainer"); + + let binding = echo_service_binding(); + let handlers: Vec> = vec![Box::new(SayHandler), Box::new(FailHandler)]; + + let service = grpc_nats_micro::serve(&client, &binding, content_type_policy, handlers) + .await + .expect("start EchoService"); + + EchoFixture { + _server: server, + client, + binding, + _service: service, + } +} + +async fn say(fixture: &EchoFixture, content_type: ContentType, message: &str) -> async_nats::Message { + let endpoint = fixture + .binding + .endpoints() + .iter() + .find(|endpoint| endpoint.method_name().as_str() == SAY_METHOD) + .expect("Say endpoint registered"); + + let request = SayRequest { + message: Some(message.to_string()), + }; + let body = content_type.encode(&request).expect("encode SayRequest"); + let mut headers = HeaderMap::new(); + headers.insert( + grpc_nats_micro::constants::HEADER_CONTENT_TYPE, + content_type.header_value(), + ); + + let subject = endpoint.subject().as_str().to_string(); + tokio::time::timeout( + REQUEST_TIMEOUT, + RequestClient::request_with_headers(&fixture.client, subject, headers, body.into()), + ) + .await + .expect("Say request did not time out") + .expect("Say request succeeded") +} + +async fn fail(fixture: &EchoFixture, content_type: ContentType, code: Code, message: &str) -> async_nats::Message { + let endpoint = fixture + .binding + .endpoints() + .iter() + .find(|endpoint| endpoint.method_name().as_str() == FAIL_METHOD) + .expect("Fail endpoint registered"); + + let request = FailRequest { + code: Some(code.into()), + message: Some(message.to_string()), + }; + let body = content_type.encode(&request).expect("encode FailRequest"); + let mut headers = HeaderMap::new(); + headers.insert( + grpc_nats_micro::constants::HEADER_CONTENT_TYPE, + content_type.header_value(), + ); + + let subject = endpoint.subject().as_str().to_string(); + tokio::time::timeout( + REQUEST_TIMEOUT, + RequestClient::request_with_headers(&fixture.client, subject, headers, body.into()), + ) + .await + .expect("Fail request did not time out") + .expect("Fail request succeeded") +} + +async fn assert_say_round_trips(content_type: ContentType) { + let fixture = start_fixture().await; + + let response = say(&fixture, content_type, "hello").await; + + assert!( + response + .headers + .as_ref() + .and_then(|headers| headers.get(HEADER_ERROR_CODE)) + .is_none(), + "successful Say reply must not carry {HEADER_ERROR_CODE}" + ); + let reply: SayResponse = content_type.decode(&response.payload).expect("decode SayResponse"); + assert_eq!(reply.message, Some("hello".to_string())); +} + +async fn assert_fail_reports_status(content_type: ContentType) { + let fixture = start_fixture().await; + + let response = fail(&fixture, content_type, Code::ALREADY_EXISTS, "already exists").await; + + let headers = response.headers.as_ref().expect("error reply carries headers"); + let error_code_header = headers + .get(HEADER_ERROR_CODE) + .expect("error reply must carry Nats-Service-Error-Code") + .as_str(); + assert_eq!(error_code_header, Code::ALREADY_EXISTS.to_i32().to_string()); + + let status: Status = content_type + .decode(&response.payload) + .expect("decode complete Status body"); + assert_eq!(status.code, Code::ALREADY_EXISTS.to_i32()); + assert_eq!(status.message, "already exists"); + assert_eq!(error_info(&status).reason, FAIL_DETAIL_REASON); +} + +/// ADR 0016 §3 makes the body the only place `Status.details` is readable, so +/// the client decode path must surface them rather than reduce the fault to +/// code and message. +async fn assert_fail_details_reach_the_client(content_type: ContentType) { + let fixture = start_fixture_with_policy(ServiceOptions::default()).await; + let endpoint = endpoint(&fixture, FAIL_METHOD); + + let request = FailRequest { + code: Some(Code::RESOURCE_EXHAUSTED.into()), + message: Some("out of quota".to_string()), + }; + let error = grpc_nats_micro::client::request::<_, FailRequest, FailResponse>( + &fixture.client, + endpoint, + content_type, + &request, + REQUEST_TIMEOUT, + ) + .await + .expect_err("Fail must surface a service error"); + + let service_error = service_error(error); + assert_eq!(service_error.code().code(), Code::RESOURCE_EXHAUSTED); + assert_eq!(service_error.message(), "out of quota"); + let detail = error_info(service_error.status()); + assert_eq!(detail.reason, FAIL_DETAIL_REASON); + assert_eq!(detail.domain, FAIL_DETAIL_DOMAIN); +} + +fn error_info(status: &Status) -> ErrorInfo { + status + .details + .first() + .expect("Status carries an error detail") + .unpack_if::(ErrorInfo::TYPE_URL) + .expect("decode ErrorInfo detail") + .expect("detail is an ErrorInfo") +} + +fn service_error( + error: grpc_nats_micro::client::RequestError, +) -> grpc_nats_micro::ServiceError { + match error { + grpc_nats_micro::client::RequestError::Reply(ReplyError::Service(service_error)) => service_error, + other => panic!("expected a micro service error, got {other:?}"), + } +} + +fn endpoint<'a>(fixture: &'a EchoFixture, method_name: &str) -> &'a grpc_nats_micro::EndpointBinding { + fixture + .binding + .endpoints() + .iter() + .find(|endpoint| endpoint.method_name().as_str() == method_name) + .expect("endpoint registered") +} + +#[tokio::test] +async fn fail_details_reach_the_client_over_protobuf() { + assert_fail_details_reach_the_client(ContentType::Protobuf).await; +} + +#[tokio::test] +async fn fail_details_reach_the_client_over_json() { + assert_fail_details_reach_the_client(ContentType::Json).await; +} + +/// The service's own `$SRV.INFO` record, decoded. +async fn service_info(client: &async_nats::Client) -> serde_json::Value { + let response = tokio::time::timeout( + REQUEST_TIMEOUT, + client.request(format!("$SRV.INFO.{SERVICE_NAME}"), bytes::Bytes::new()), + ) + .await + .expect("$SRV.INFO did not time out") + .expect("$SRV.INFO responded"); + + serde_json::from_slice(&response.payload).expect("decode $SRV.INFO record") +} + +/// ADR 0016 §2: the endpoint name is the rpc method name, so discovery reports +/// the method rather than the subject micro would otherwise name it after. +#[tokio::test] +async fn discovery_names_endpoints_after_rpc_methods() { + let fixture = start_fixture().await; + + let info = service_info(&fixture.client).await; + assert_eq!(info["description"].as_str(), Some(SERVICE_DESCRIPTION)); + let mut names: Vec<&str> = info["endpoints"] + .as_array() + .expect("$SRV.INFO carries endpoints") + .iter() + .map(|endpoint| endpoint["name"].as_str().expect("endpoint name is a string")) + .collect(); + names.sort_unstable(); + assert_eq!(names, vec![FAIL_METHOD, SAY_METHOD]); +} + +/// ADR 0016 §1: `ServiceOptions.metadata` populates the service's discovery +/// record and `MethodOptions.metadata` populates its endpoint's, so an +/// annotation such as `Fail`'s `always-faults` has to survive the binding and +/// reach `$SRV.INFO`. +#[tokio::test] +async fn discovery_carries_the_annotated_metadata() { + let fixture = start_fixture().await; + + let info = service_info(&fixture.client).await; + + assert_eq!( + info["metadata"][SERVICE_METADATA_KEY].as_str(), + Some(SERVICE_METADATA_VALUE) + ); + let fail = info["endpoints"] + .as_array() + .expect("$SRV.INFO carries endpoints") + .iter() + .find(|endpoint| endpoint["name"].as_str() == Some(FAIL_METHOD)) + .expect("Fail endpoint in $SRV.INFO"); + assert_eq!(fail["metadata"][FAIL_METADATA_KEY].as_str(), Some(FAIL_METADATA_VALUE)); + + let say = info["endpoints"] + .as_array() + .expect("$SRV.INFO carries endpoints") + .iter() + .find(|endpoint| endpoint["name"].as_str() == Some(SAY_METHOD)) + .expect("Say endpoint in $SRV.INFO"); + assert!( + say["metadata"][FAIL_METADATA_KEY].is_null(), + "a method that declares no metadata must not inherit another's, got {say}" + ); +} + +/// A caller the content-type policy turns away must be able to read the +/// rejection: encoding it in a type the caller does not speak would surface as +/// a decode failure instead of the policy's `Status`. +#[tokio::test] +async fn rejected_content_type_reports_the_policy_status() { + let fixture = start_fixture_with_policy(ServiceOptions { + content_type: ProtoContentType::CONTENT_TYPE_PROTOBUF.into(), + ..Default::default() + }) + .await; + let endpoint = endpoint(&fixture, SAY_METHOD); + + let request = SayRequest { + message: Some("hello".to_string()), + }; + let error = grpc_nats_micro::client::request::<_, SayRequest, SayResponse>( + &fixture.client, + endpoint, + ContentType::Json, + &request, + REQUEST_TIMEOUT, + ) + .await + .expect_err("a JSON caller must be rejected by a protobuf-only service"); + + let service_error = service_error(error); + assert_eq!(service_error.code().code(), Code::INVALID_ARGUMENT); +} + +#[tokio::test] +async fn say_round_trips_over_protobuf() { + assert_say_round_trips(ContentType::Protobuf).await; +} + +#[tokio::test] +async fn say_round_trips_over_json() { + assert_say_round_trips(ContentType::Json).await; +} + +#[tokio::test] +async fn fail_reports_status_over_protobuf() { + assert_fail_reports_status(ContentType::Protobuf).await; +} + +#[tokio::test] +async fn fail_reports_status_over_json() { + assert_fail_reports_status(ContentType::Json).await; +} + +/// A handler list that does not line up with the binding's endpoints would be +/// silently truncated by `zip`, leaving declared methods unserved. +#[tokio::test] +async fn a_handler_count_mismatch_is_rejected_before_startup() { + let server = CoreTestServer::start().await; + let client = connect(&server).await.expect("connect to the NATS testcontainer"); + let binding = echo_service_binding(); + + let error = grpc_nats_micro::serve( + &client, + &binding, + ServiceOptions::default(), + vec![Box::new(SayHandler) as Box], + ) + .await + .expect_err("a short handler list must be rejected"); + + assert!(matches!( + error, + grpc_nats_micro::ServeError::HandlerCount { + endpoints: 2, + handlers: 1 + } + )); +} + +/// Stopping the service unsubscribes its endpoints, which ends the dispatch +/// task each one runs on and leaves the subject without a responder. +#[tokio::test] +async fn stopping_the_service_leaves_its_subjects_without_a_responder() { + let server = CoreTestServer::start().await; + let client = connect(&server).await.expect("connect to the NATS testcontainer"); + let binding = echo_service_binding(); + let handlers: Vec> = vec![Box::new(SayHandler), Box::new(FailHandler)]; + let service = grpc_nats_micro::serve(&client, &binding, ServiceOptions::default(), handlers) + .await + .expect("start EchoService"); + + service.stop().await.expect("stop EchoService"); + + let endpoint = binding + .endpoints() + .iter() + .find(|endpoint| endpoint.method_name().as_str() == SAY_METHOD) + .expect("Say endpoint registered"); + let error = wait_for_no_responder(&client, endpoint).await; + + assert!( + matches!(error, RequestError::Transport { .. }), + "expected no responder, got {error:?}" + ); +} + +/// Request `endpoint` until nobody answers, and report the failure that ended +/// the wait. +/// +/// `Service::stop` only broadcasts the shutdown, and a stopped endpoint reaches +/// "no responder" in two steps: its dispatch task stops consuming, then its +/// unsubscribe reaches the server. Between the two the subject still has a +/// subscription nobody reads, so a request there times out instead of finding +/// no responder. Retrying until the subscription is actually gone waits for the +/// state under test rather than guessing how long those steps take. +async fn wait_for_no_responder(client: &N, endpoint: &EndpointBinding) -> RequestError +where + N: RequestClient, + N::RequestError: 'static, +{ + let deadline = tokio::time::Instant::now() + STOPPED_SERVICE_DEADLINE; + let request = SayRequest { + message: Some("hello".to_string()), + }; + + loop { + let outcome = grpc_nats_micro::client::request::<_, SayRequest, SayResponse>( + client, + endpoint, + ContentType::Protobuf, + &request, + STOPPED_SERVICE_TIMEOUT, + ) + .await; + + match outcome { + Err(error @ RequestError::Transport { .. }) => return error, + settling => assert!( + tokio::time::Instant::now() < deadline, + "the stopped service still had a subscription after {STOPPED_SERVICE_DEADLINE:?}, \ + last attempt: {:?}", + settling.map(|_: SayResponse| "answered") + ), + } + + tokio::time::sleep(STOPPED_SERVICE_POLL).await; + } +} diff --git a/rsworkspace/crates/platform/trogon-nats/src/test_support.rs b/rsworkspace/crates/platform/trogon-nats/src/test_support.rs index 80d8a99a10..981702e7f0 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/test_support.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/test_support.rs @@ -10,22 +10,22 @@ const NATS_IMAGE_TAG: &str = "2.10.14"; const NATS_CLIENT_PORT: u16 = 4222; const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); -/// An isolated NATS server with JetStream enabled. -pub struct JetStreamTestServer { +/// The pinned NATS image, started and reachable, without saying which server +/// features the test needs. Both public servers wrap one of these so the image +/// tag, the published port, and the connect timeout are decided once. +struct TestServer { _container: ContainerAsync, address: String, } -impl JetStreamTestServer { - /// Starts the pinned NATS image and waits until it accepts client connections. - pub async fn start() -> Self { - let command = NatsServerCmd::default().with_jetstream(); +impl TestServer { + async fn start(command: &NatsServerCmd) -> Self { let container = Nats::default() .with_tag(NATS_IMAGE_TAG) - .with_cmd(&command) + .with_cmd(command) .start() .await - .expect("start JetStream testcontainer"); + .expect("start NATS testcontainer"); let host = container.get_host().await.expect("get NATS testcontainer host"); let port = container .get_host_port_ipv4(NATS_CLIENT_PORT) @@ -38,6 +38,24 @@ impl JetStreamTestServer { } } + async fn client(&self) -> async_nats::Client { + async_nats::ConnectOptions::new() + .connection_timeout(CONNECT_TIMEOUT) + .connect(&self.address) + .await + .expect("connect to NATS testcontainer") + } +} + +/// An isolated NATS server with JetStream enabled. +pub struct JetStreamTestServer(TestServer); + +impl JetStreamTestServer { + /// Starts the pinned NATS image and waits until it accepts client connections. + pub async fn start() -> Self { + Self(TestServer::start(&NatsServerCmd::default().with_jetstream()).await) + } + /// Connects to the isolated server and returns its JetStream context. pub async fn jetstream(&self) -> jetstream::Context { jetstream::new(self.client().await) @@ -46,10 +64,26 @@ impl JetStreamTestServer { /// A raw connection to the isolated server, for tests that need a context /// built some other way (a non-default API prefix, a domain). pub async fn client(&self) -> async_nats::Client { - async_nats::ConnectOptions::new() - .connection_timeout(CONNECT_TIMEOUT) - .connect(&self.address) - .await - .expect("connect to JetStream testcontainer") + self.0.client().await + } +} + +/// An isolated NATS server with only core NATS, for bindings that live on +/// request/reply and NATS Services rather than on streams. +/// +/// JetStream is left off deliberately: a test that never opens a stream should +/// not be able to pass by accidentally depending on one. +pub struct CoreTestServer(TestServer); + +impl CoreTestServer { + /// Starts the pinned NATS image and waits until it accepts client connections. + pub async fn start() -> Self { + Self(TestServer::start(&NatsServerCmd::default()).await) + } + + /// The `host:port` this server is reachable on, for tests that connect + /// through their own configuration rather than a raw client. + pub fn address(&self) -> &str { + &self.0.address } } diff --git a/rsworkspace/crates/platform/trogonai-proto/Cargo.toml b/rsworkspace/crates/platform/trogonai-proto/Cargo.toml index e60011fc11..0ba6e8d296 100644 --- a/rsworkspace/crates/platform/trogonai-proto/Cargo.toml +++ b/rsworkspace/crates/platform/trogonai-proto/Cargo.toml @@ -12,6 +12,10 @@ workspace = true default = [] chrono = ["dep:buffa-types", "dep:chrono"] schedules = ["dep:buffa", "dep:buffa-types", "dep:serde", "dep:trogon-decider", "chrono"] +# Generated types for the grpc-nats-micro binding (ADR 0016): google.rpc.Status/Code, +# the trogon.nats.micro.v1alpha1 options, and the echo conformance fixture. Deliberately +# excludes the decider/scheduler domain deps so the binding does not couple to them. +grpc-nats-micro = ["dep:buffa", "dep:buffa-types", "dep:serde"] runtime-snapshot = ["schedules", "dep:trogon-decider-runtime"] runtime-host = ["schedules", "dep:trogon-decider-runtime"] agents = ["dep:buffa", "dep:buffa-types", "dep:serde", "dep:trogon-decider"] diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/mod.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/mod.rs index b15991add5..ec896ece37 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/mod.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/mod.rs @@ -281,6 +281,35 @@ pub mod trogonai { clippy::doc_lazy_continuation, clippy::module_inception )] + pub mod grpc_nats_micro { + use super::*; + #[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception + )] + pub mod v1 { + use super::*; + include!("trogonai.grpc_nats_micro.v1.mod.rs"); + } + } + #[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception + )] pub mod scheduler { use super::*; #[allow( diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.__view.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.__view.rs new file mode 100644 index 0000000000..7a3463ad65 --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.__view.rs @@ -0,0 +1,987 @@ +// @generated by buffa-codegen. DO NOT EDIT. +// source: trogonai/grpc_nats_micro/v1/echo.proto + +#[derive(Clone, Debug, Default)] +pub struct SayRequestView<'a> { + /// Field 1: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for SayRequestView<'a> { + type Owned = super::super::SayRequest; + fn decode_view(buf: &'a [u8]) -> ::core::result::Result { + let __limit = ::core::cell::Cell::new(::buffa::DEFAULT_UNKNOWN_FIELD_LIMIT); + ::decode_view_ctx( + buf, + ::buffa::DecodeContext::new(::buffa::RECURSION_LIMIT, &__limit), + ) + } + fn decode_view_with_ctx( + buf: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result { + ::decode_view_ctx(buf, ctx) + } + #[inline] + fn merge_view_field( + &mut self, + tag: ::buffa::encoding::Tag, + cur: &'a [u8], + _before_tag: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<&'a [u8], ::buffa::DecodeError> { + let _ = ctx; + #[allow(unused_variables)] + let view = self; + let mut cur = cur; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + view.message = Some(::buffa::types::borrow_str(&mut cur)?); + } + _ => { + ::buffa::encoding::skip_field_depth(tag, &mut cur, ctx.depth())?; + } + } + ::core::result::Result::Ok(cur) + } + fn to_owned_message( + &self, + ) -> ::core::result::Result { + self.to_owned_from_source(None) + } + #[allow(clippy::useless_conversion, clippy::needless_update)] + fn to_owned_from_source( + &self, + __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, + ) -> ::core::result::Result { + #[allow(unused_imports)] + use ::buffa::alloc::string::ToString as _; + let _ = __buffa_src; + ::core::result::Result::Ok(super::super::SayRequest { + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for SayRequestView<'a> { + #[allow(clippy::needless_borrow, clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + #[allow(clippy::needless_borrow)] + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } +} +/// Serializes this view as protobuf JSON. +/// +/// Implicit-presence fields with default values are omitted, `required` +/// fields are always emitted, explicit-presence (`optional`) fields are +/// emitted only when set, bytes fields are base64-encoded, and enum +/// values are their proto name strings. +/// +/// This impl uses `serialize_map(None)` because the number of emitted +/// fields depends on default-omission rules; serializers that require +/// known map lengths (e.g. `bincode`) will return a runtime error. +/// Use the owned message type for those formats. +impl<'__a> ::serde::Serialize for SayRequestView<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + if let ::core::option::Option::Some(__v) = self.message { + __map.serialize_entry("message", __v)?; + } + __map.end() + } +} +impl<'a> ::buffa::MessageName for SayRequestView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "SayRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.SayRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayRequest"; +} +::buffa::impl_default_view_instance!(SayRequestView); +::buffa::impl_view_reborrow!(SayRequestView); +/** Self-contained, `'static` owned view of a `SayRequest` message. + + Wraps [`::buffa::OwnedView`]`<`[`SayRequestView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`SayRequestView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct SayRequestOwnedView(::buffa::OwnedView>); +impl SayRequestOwnedView { + /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. + /// + /// The view borrows directly from the buffer's data; the buffer is + /// retained inside the returned handle. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer contains invalid + /// protobuf data. + pub fn decode( + bytes: ::buffa::bytes::Bytes, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayRequestOwnedView(::buffa::OwnedView::decode(bytes)?), + ) + } + /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, + /// max message size). + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer is invalid or + /// exceeds the configured limits. + pub fn decode_with_options( + bytes: ::buffa::bytes::Bytes, + opts: &::buffa::DecodeOptions, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayRequestOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ) + } + /// Build from an owned message via an encode → decode round-trip. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError::MessageTooLarge`] if the + /// message's encoded size exceeds the 2 GiB protobuf limit, or + /// another [`::buffa::DecodeError`] if the re-encoded bytes are + /// somehow invalid (should not happen for well-formed messages). + pub fn from_owned( + msg: &super::super::SayRequest, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayRequestOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`SayRequestView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &SayRequestView<'_> { + self.0.reborrow() + } + /// Convert to the owned message type. + /// + /// Infallible: this type's constructors wire-decode their + /// buffer, and a view produced by wire decoding always + /// converts. Delegates to [`::buffa::OwnedView::to_owned_message`], + /// whose contract also governs handles converted from a raw + /// [`::buffa::OwnedView`]. + #[must_use] + pub fn to_owned_message(&self) -> super::super::SayRequest { + self.0.to_owned_message() + } + /// The underlying bytes buffer. + #[must_use] + pub fn bytes(&self) -> &::buffa::bytes::Bytes { + self.0.bytes() + } + /// Consume the handle, returning the underlying bytes buffer. + #[must_use] + pub fn into_bytes(self) -> ::buffa::bytes::Bytes { + self.0.into_bytes() + } + /// Field 1: `message` + #[must_use] + pub fn message(&self) -> ::core::option::Option<&'_ str> { + self.0.reborrow().message + } +} +impl ::core::convert::From<::buffa::OwnedView>> +for SayRequestOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + SayRequestOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: SayRequestOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for SayRequestOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::SayRequest { + type View<'a> = SayRequestView<'a>; + type ViewHandle = SayRequestOwnedView; +} +impl ::serde::Serialize for SayRequestOwnedView { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + ::serde::Serialize::serialize(&self.0, __s) + } +} +#[derive(Clone, Debug, Default)] +pub struct SayResponseView<'a> { + /// Field 1: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for SayResponseView<'a> { + type Owned = super::super::SayResponse; + fn decode_view(buf: &'a [u8]) -> ::core::result::Result { + let __limit = ::core::cell::Cell::new(::buffa::DEFAULT_UNKNOWN_FIELD_LIMIT); + ::decode_view_ctx( + buf, + ::buffa::DecodeContext::new(::buffa::RECURSION_LIMIT, &__limit), + ) + } + fn decode_view_with_ctx( + buf: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result { + ::decode_view_ctx(buf, ctx) + } + #[inline] + fn merge_view_field( + &mut self, + tag: ::buffa::encoding::Tag, + cur: &'a [u8], + _before_tag: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<&'a [u8], ::buffa::DecodeError> { + let _ = ctx; + #[allow(unused_variables)] + let view = self; + let mut cur = cur; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + view.message = Some(::buffa::types::borrow_str(&mut cur)?); + } + _ => { + ::buffa::encoding::skip_field_depth(tag, &mut cur, ctx.depth())?; + } + } + ::core::result::Result::Ok(cur) + } + fn to_owned_message( + &self, + ) -> ::core::result::Result { + self.to_owned_from_source(None) + } + #[allow(clippy::useless_conversion, clippy::needless_update)] + fn to_owned_from_source( + &self, + __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, + ) -> ::core::result::Result { + #[allow(unused_imports)] + use ::buffa::alloc::string::ToString as _; + let _ = __buffa_src; + ::core::result::Result::Ok(super::super::SayResponse { + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for SayResponseView<'a> { + #[allow(clippy::needless_borrow, clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + #[allow(clippy::needless_borrow)] + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } +} +/// Serializes this view as protobuf JSON. +/// +/// Implicit-presence fields with default values are omitted, `required` +/// fields are always emitted, explicit-presence (`optional`) fields are +/// emitted only when set, bytes fields are base64-encoded, and enum +/// values are their proto name strings. +/// +/// This impl uses `serialize_map(None)` because the number of emitted +/// fields depends on default-omission rules; serializers that require +/// known map lengths (e.g. `bincode`) will return a runtime error. +/// Use the owned message type for those formats. +impl<'__a> ::serde::Serialize for SayResponseView<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + if let ::core::option::Option::Some(__v) = self.message { + __map.serialize_entry("message", __v)?; + } + __map.end() + } +} +impl<'a> ::buffa::MessageName for SayResponseView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "SayResponse"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.SayResponse"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayResponse"; +} +::buffa::impl_default_view_instance!(SayResponseView); +::buffa::impl_view_reborrow!(SayResponseView); +/** Self-contained, `'static` owned view of a `SayResponse` message. + + Wraps [`::buffa::OwnedView`]`<`[`SayResponseView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`SayResponseView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct SayResponseOwnedView(::buffa::OwnedView>); +impl SayResponseOwnedView { + /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. + /// + /// The view borrows directly from the buffer's data; the buffer is + /// retained inside the returned handle. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer contains invalid + /// protobuf data. + pub fn decode( + bytes: ::buffa::bytes::Bytes, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayResponseOwnedView(::buffa::OwnedView::decode(bytes)?), + ) + } + /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, + /// max message size). + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer is invalid or + /// exceeds the configured limits. + pub fn decode_with_options( + bytes: ::buffa::bytes::Bytes, + opts: &::buffa::DecodeOptions, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayResponseOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ) + } + /// Build from an owned message via an encode → decode round-trip. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError::MessageTooLarge`] if the + /// message's encoded size exceeds the 2 GiB protobuf limit, or + /// another [`::buffa::DecodeError`] if the re-encoded bytes are + /// somehow invalid (should not happen for well-formed messages). + pub fn from_owned( + msg: &super::super::SayResponse, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + SayResponseOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`SayResponseView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &SayResponseView<'_> { + self.0.reborrow() + } + /// Convert to the owned message type. + /// + /// Infallible: this type's constructors wire-decode their + /// buffer, and a view produced by wire decoding always + /// converts. Delegates to [`::buffa::OwnedView::to_owned_message`], + /// whose contract also governs handles converted from a raw + /// [`::buffa::OwnedView`]. + #[must_use] + pub fn to_owned_message(&self) -> super::super::SayResponse { + self.0.to_owned_message() + } + /// The underlying bytes buffer. + #[must_use] + pub fn bytes(&self) -> &::buffa::bytes::Bytes { + self.0.bytes() + } + /// Consume the handle, returning the underlying bytes buffer. + #[must_use] + pub fn into_bytes(self) -> ::buffa::bytes::Bytes { + self.0.into_bytes() + } + /// Field 1: `message` + #[must_use] + pub fn message(&self) -> ::core::option::Option<&'_ str> { + self.0.reborrow().message + } +} +impl ::core::convert::From<::buffa::OwnedView>> +for SayResponseOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + SayResponseOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: SayResponseOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for SayResponseOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::SayResponse { + type View<'a> = SayResponseView<'a>; + type ViewHandle = SayResponseOwnedView; +} +impl ::serde::Serialize for SayResponseOwnedView { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + ::serde::Serialize::serialize(&self.0, __s) + } +} +#[derive(Clone, Debug, Default)] +pub struct FailRequestView<'a> { + /// Canonical status code the handler should fault with. + /// + /// Field 1: `code` + pub code: ::core::option::Option< + ::buffa::EnumValue, + >, + /// Field 2: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for FailRequestView<'a> { + type Owned = super::super::FailRequest; + fn decode_view(buf: &'a [u8]) -> ::core::result::Result { + let __limit = ::core::cell::Cell::new(::buffa::DEFAULT_UNKNOWN_FIELD_LIMIT); + ::decode_view_ctx( + buf, + ::buffa::DecodeContext::new(::buffa::RECURSION_LIMIT, &__limit), + ) + } + fn decode_view_with_ctx( + buf: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result { + ::decode_view_ctx(buf, ctx) + } + #[inline] + fn merge_view_field( + &mut self, + tag: ::buffa::encoding::Tag, + cur: &'a [u8], + _before_tag: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<&'a [u8], ::buffa::DecodeError> { + let _ = ctx; + #[allow(unused_variables)] + let view = self; + let mut cur = cur; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::Varint, + )?; + view.code = Some( + ::buffa::EnumValue::from(::buffa::types::decode_int32(&mut cur)?), + ); + } + 2u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + view.message = Some(::buffa::types::borrow_str(&mut cur)?); + } + _ => { + ::buffa::encoding::skip_field_depth(tag, &mut cur, ctx.depth())?; + } + } + ::core::result::Result::Ok(cur) + } + fn to_owned_message( + &self, + ) -> ::core::result::Result { + self.to_owned_from_source(None) + } + #[allow(clippy::useless_conversion, clippy::needless_update)] + fn to_owned_from_source( + &self, + __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, + ) -> ::core::result::Result { + #[allow(unused_imports)] + use ::buffa::alloc::string::ToString as _; + let _ = __buffa_src; + ::core::result::Result::Ok(super::super::FailRequest { + code: self.code, + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for FailRequestView<'a> { + #[allow(clippy::needless_borrow, clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.code { + size += 1u64 + ::buffa::types::int32_encoded_len(v.to_i32()) as u64; + } + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + #[allow(clippy::needless_borrow)] + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.code { + ::buffa::types::put_int32_field(1u32, v.to_i32(), buf); + } + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(2u32, v, buf); + } + } +} +/// Serializes this view as protobuf JSON. +/// +/// Implicit-presence fields with default values are omitted, `required` +/// fields are always emitted, explicit-presence (`optional`) fields are +/// emitted only when set, bytes fields are base64-encoded, and enum +/// values are their proto name strings. +/// +/// This impl uses `serialize_map(None)` because the number of emitted +/// fields depends on default-omission rules; serializers that require +/// known map lengths (e.g. `bincode`) will return a runtime error. +/// Use the owned message type for those formats. +impl<'__a> ::serde::Serialize for FailRequestView<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + if let ::core::option::Option::Some(ref __v) = self.code { + __map.serialize_entry("code", __v)?; + } + if let ::core::option::Option::Some(__v) = self.message { + __map.serialize_entry("message", __v)?; + } + __map.end() + } +} +impl<'a> ::buffa::MessageName for FailRequestView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "FailRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.FailRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailRequest"; +} +::buffa::impl_default_view_instance!(FailRequestView); +::buffa::impl_view_reborrow!(FailRequestView); +/** Self-contained, `'static` owned view of a `FailRequest` message. + + Wraps [`::buffa::OwnedView`]`<`[`FailRequestView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`FailRequestView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct FailRequestOwnedView(::buffa::OwnedView>); +impl FailRequestOwnedView { + /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. + /// + /// The view borrows directly from the buffer's data; the buffer is + /// retained inside the returned handle. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer contains invalid + /// protobuf data. + pub fn decode( + bytes: ::buffa::bytes::Bytes, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailRequestOwnedView(::buffa::OwnedView::decode(bytes)?), + ) + } + /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, + /// max message size). + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer is invalid or + /// exceeds the configured limits. + pub fn decode_with_options( + bytes: ::buffa::bytes::Bytes, + opts: &::buffa::DecodeOptions, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailRequestOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ) + } + /// Build from an owned message via an encode → decode round-trip. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError::MessageTooLarge`] if the + /// message's encoded size exceeds the 2 GiB protobuf limit, or + /// another [`::buffa::DecodeError`] if the re-encoded bytes are + /// somehow invalid (should not happen for well-formed messages). + pub fn from_owned( + msg: &super::super::FailRequest, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailRequestOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`FailRequestView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &FailRequestView<'_> { + self.0.reborrow() + } + /// Convert to the owned message type. + /// + /// Infallible: this type's constructors wire-decode their + /// buffer, and a view produced by wire decoding always + /// converts. Delegates to [`::buffa::OwnedView::to_owned_message`], + /// whose contract also governs handles converted from a raw + /// [`::buffa::OwnedView`]. + #[must_use] + pub fn to_owned_message(&self) -> super::super::FailRequest { + self.0.to_owned_message() + } + /// The underlying bytes buffer. + #[must_use] + pub fn bytes(&self) -> &::buffa::bytes::Bytes { + self.0.bytes() + } + /// Consume the handle, returning the underlying bytes buffer. + #[must_use] + pub fn into_bytes(self) -> ::buffa::bytes::Bytes { + self.0.into_bytes() + } + /// Canonical status code the handler should fault with. + /// + /// Field 1: `code` + #[must_use] + pub fn code( + &self, + ) -> ::core::option::Option< + ::buffa::EnumValue, + > { + self.0.reborrow().code + } + /// Field 2: `message` + #[must_use] + pub fn message(&self) -> ::core::option::Option<&'_ str> { + self.0.reborrow().message + } +} +impl ::core::convert::From<::buffa::OwnedView>> +for FailRequestOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + FailRequestOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: FailRequestOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for FailRequestOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::FailRequest { + type View<'a> = FailRequestView<'a>; + type ViewHandle = FailRequestOwnedView; +} +impl ::serde::Serialize for FailRequestOwnedView { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + ::serde::Serialize::serialize(&self.0, __s) + } +} +#[derive(Clone, Debug, Default)] +pub struct FailResponseView<'a> { + /// Field 1: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for FailResponseView<'a> { + type Owned = super::super::FailResponse; + fn decode_view(buf: &'a [u8]) -> ::core::result::Result { + let __limit = ::core::cell::Cell::new(::buffa::DEFAULT_UNKNOWN_FIELD_LIMIT); + ::decode_view_ctx( + buf, + ::buffa::DecodeContext::new(::buffa::RECURSION_LIMIT, &__limit), + ) + } + fn decode_view_with_ctx( + buf: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result { + ::decode_view_ctx(buf, ctx) + } + #[inline] + fn merge_view_field( + &mut self, + tag: ::buffa::encoding::Tag, + cur: &'a [u8], + _before_tag: &'a [u8], + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<&'a [u8], ::buffa::DecodeError> { + let _ = ctx; + #[allow(unused_variables)] + let view = self; + let mut cur = cur; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + view.message = Some(::buffa::types::borrow_str(&mut cur)?); + } + _ => { + ::buffa::encoding::skip_field_depth(tag, &mut cur, ctx.depth())?; + } + } + ::core::result::Result::Ok(cur) + } + fn to_owned_message( + &self, + ) -> ::core::result::Result { + self.to_owned_from_source(None) + } + #[allow(clippy::useless_conversion, clippy::needless_update)] + fn to_owned_from_source( + &self, + __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, + ) -> ::core::result::Result { + #[allow(unused_imports)] + use ::buffa::alloc::string::ToString as _; + let _ = __buffa_src; + ::core::result::Result::Ok(super::super::FailResponse { + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for FailResponseView<'a> { + #[allow(clippy::needless_borrow, clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + #[allow(clippy::needless_borrow)] + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } +} +/// Serializes this view as protobuf JSON. +/// +/// Implicit-presence fields with default values are omitted, `required` +/// fields are always emitted, explicit-presence (`optional`) fields are +/// emitted only when set, bytes fields are base64-encoded, and enum +/// values are their proto name strings. +/// +/// This impl uses `serialize_map(None)` because the number of emitted +/// fields depends on default-omission rules; serializers that require +/// known map lengths (e.g. `bincode`) will return a runtime error. +/// Use the owned message type for those formats. +impl<'__a> ::serde::Serialize for FailResponseView<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + if let ::core::option::Option::Some(__v) = self.message { + __map.serialize_entry("message", __v)?; + } + __map.end() + } +} +impl<'a> ::buffa::MessageName for FailResponseView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "FailResponse"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.FailResponse"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailResponse"; +} +::buffa::impl_default_view_instance!(FailResponseView); +::buffa::impl_view_reborrow!(FailResponseView); +/** Self-contained, `'static` owned view of a `FailResponse` message. + + Wraps [`::buffa::OwnedView`]`<`[`FailResponseView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`FailResponseView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct FailResponseOwnedView(::buffa::OwnedView>); +impl FailResponseOwnedView { + /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. + /// + /// The view borrows directly from the buffer's data; the buffer is + /// retained inside the returned handle. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer contains invalid + /// protobuf data. + pub fn decode( + bytes: ::buffa::bytes::Bytes, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailResponseOwnedView(::buffa::OwnedView::decode(bytes)?), + ) + } + /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, + /// max message size). + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer is invalid or + /// exceeds the configured limits. + pub fn decode_with_options( + bytes: ::buffa::bytes::Bytes, + opts: &::buffa::DecodeOptions, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailResponseOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ) + } + /// Build from an owned message via an encode → decode round-trip. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError::MessageTooLarge`] if the + /// message's encoded size exceeds the 2 GiB protobuf limit, or + /// another [`::buffa::DecodeError`] if the re-encoded bytes are + /// somehow invalid (should not happen for well-formed messages). + pub fn from_owned( + msg: &super::super::FailResponse, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + FailResponseOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`FailResponseView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &FailResponseView<'_> { + self.0.reborrow() + } + /// Convert to the owned message type. + /// + /// Infallible: this type's constructors wire-decode their + /// buffer, and a view produced by wire decoding always + /// converts. Delegates to [`::buffa::OwnedView::to_owned_message`], + /// whose contract also governs handles converted from a raw + /// [`::buffa::OwnedView`]. + #[must_use] + pub fn to_owned_message(&self) -> super::super::FailResponse { + self.0.to_owned_message() + } + /// The underlying bytes buffer. + #[must_use] + pub fn bytes(&self) -> &::buffa::bytes::Bytes { + self.0.bytes() + } + /// Consume the handle, returning the underlying bytes buffer. + #[must_use] + pub fn into_bytes(self) -> ::buffa::bytes::Bytes { + self.0.into_bytes() + } + /// Field 1: `message` + #[must_use] + pub fn message(&self) -> ::core::option::Option<&'_ str> { + self.0.reborrow().message + } +} +impl ::core::convert::From<::buffa::OwnedView>> +for FailResponseOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + FailResponseOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: FailResponseOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for FailResponseOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::FailResponse { + type View<'a> = FailResponseView<'a>; + type ViewHandle = FailResponseOwnedView; +} +impl ::serde::Serialize for FailResponseOwnedView { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + ::serde::Serialize::serialize(&self.0, __s) + } +} diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.rs new file mode 100644 index 0000000000..4f2e7590f4 --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.rs @@ -0,0 +1,519 @@ +// @generated by buffa-codegen. DO NOT EDIT. +// source: trogonai/grpc_nats_micro/v1/echo.proto + +#[derive(Clone, PartialEq, Default)] +#[derive(::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct SayRequest { + /// Field 1: `message` + #[serde(rename = "message", skip_serializing_if = "::core::option::Option::is_none")] + pub message: ::core::option::Option<::buffa::alloc::string::String>, +} +impl ::core::fmt::Debug for SayRequest { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("SayRequest").field("message", &self.message).finish() + } +} +impl SayRequest { + /// Protobuf type URL for this message, for use with `Any::pack` and + /// `Any::unpack_if`. + /// + /// Format: `type.googleapis.com/` + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayRequest"; +} +impl SayRequest { + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::message`] to `Some(value)`, consuming and returning `self`. + pub fn with_message( + mut self, + value: impl Into<::buffa::alloc::string::String>, + ) -> Self { + self.message = Some(value.into()); + self + } +} +::buffa::impl_default_instance!(SayRequest); +impl ::buffa::MessageName for SayRequest { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "SayRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.SayRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayRequest"; +} +impl ::buffa::Message for SayRequest { + /// Returns the total encoded size in bytes. + /// + /// Accumulates in `u64` (which cannot overflow for in-memory + /// data) and saturates to `u32` at return, so a message whose + /// encoded size exceeds the 2 GiB protobuf limit yields a value + /// above [`::buffa::MAX_MESSAGE_BYTES`] that the encode entry + /// points reject, never a silently wrapped size. + #[allow(clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } + fn merge_field( + &mut self, + tag: ::buffa::encoding::Tag, + buf: &mut impl ::buffa::bytes::Buf, + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + #[allow(unused_imports)] + use ::buffa::bytes::Buf as _; + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::types::merge_string( + self.message.get_or_insert_with(::buffa::alloc::string::String::new), + buf, + )?; + } + _ => { + ::buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; + } + } + ::core::result::Result::Ok(()) + } + fn clear(&mut self) { + self.message = ::core::option::Option::None; + } +} +impl ::buffa::json_helpers::ProtoElemJson for SayRequest { + fn serialize_proto_json( + v: &Self, + s: S, + ) -> ::core::result::Result { + ::serde::Serialize::serialize(v, s) + } + fn deserialize_proto_json<'de, D: ::serde::Deserializer<'de>>( + d: D, + ) -> ::core::result::Result { + ::deserialize(d) + } +} +#[doc(hidden)] +pub const __SAY_REQUEST_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayRequest", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, + is_wkt: false, +}; +#[derive(Clone, PartialEq, Default)] +#[derive(::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct SayResponse { + /// Field 1: `message` + #[serde(rename = "message", skip_serializing_if = "::core::option::Option::is_none")] + pub message: ::core::option::Option<::buffa::alloc::string::String>, +} +impl ::core::fmt::Debug for SayResponse { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("SayResponse").field("message", &self.message).finish() + } +} +impl SayResponse { + /// Protobuf type URL for this message, for use with `Any::pack` and + /// `Any::unpack_if`. + /// + /// Format: `type.googleapis.com/` + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayResponse"; +} +impl SayResponse { + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::message`] to `Some(value)`, consuming and returning `self`. + pub fn with_message( + mut self, + value: impl Into<::buffa::alloc::string::String>, + ) -> Self { + self.message = Some(value.into()); + self + } +} +::buffa::impl_default_instance!(SayResponse); +impl ::buffa::MessageName for SayResponse { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "SayResponse"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.SayResponse"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayResponse"; +} +impl ::buffa::Message for SayResponse { + /// Returns the total encoded size in bytes. + /// + /// Accumulates in `u64` (which cannot overflow for in-memory + /// data) and saturates to `u32` at return, so a message whose + /// encoded size exceeds the 2 GiB protobuf limit yields a value + /// above [`::buffa::MAX_MESSAGE_BYTES`] that the encode entry + /// points reject, never a silently wrapped size. + #[allow(clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } + fn merge_field( + &mut self, + tag: ::buffa::encoding::Tag, + buf: &mut impl ::buffa::bytes::Buf, + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + #[allow(unused_imports)] + use ::buffa::bytes::Buf as _; + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::types::merge_string( + self.message.get_or_insert_with(::buffa::alloc::string::String::new), + buf, + )?; + } + _ => { + ::buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; + } + } + ::core::result::Result::Ok(()) + } + fn clear(&mut self) { + self.message = ::core::option::Option::None; + } +} +impl ::buffa::json_helpers::ProtoElemJson for SayResponse { + fn serialize_proto_json( + v: &Self, + s: S, + ) -> ::core::result::Result { + ::serde::Serialize::serialize(v, s) + } + fn deserialize_proto_json<'de, D: ::serde::Deserializer<'de>>( + d: D, + ) -> ::core::result::Result { + ::deserialize(d) + } +} +#[doc(hidden)] +pub const __SAY_RESPONSE_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayResponse", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, + is_wkt: false, +}; +#[derive(Clone, PartialEq, Default)] +#[derive(::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct FailRequest { + /// Canonical status code the handler should fault with. + /// + /// Field 1: `code` + #[serde( + rename = "code", + with = "::buffa::json_helpers::opt_enum", + skip_serializing_if = "::core::option::Option::is_none" + )] + pub code: ::core::option::Option< + ::buffa::EnumValue, + >, + /// Field 2: `message` + #[serde(rename = "message", skip_serializing_if = "::core::option::Option::is_none")] + pub message: ::core::option::Option<::buffa::alloc::string::String>, +} +impl ::core::fmt::Debug for FailRequest { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("FailRequest") + .field("code", &self.code) + .field("message", &self.message) + .finish() + } +} +impl FailRequest { + /// Protobuf type URL for this message, for use with `Any::pack` and + /// `Any::unpack_if`. + /// + /// Format: `type.googleapis.com/` + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailRequest"; +} +impl FailRequest { + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::code`] to `Some(value)`, consuming and returning `self`. + pub fn with_code( + mut self, + value: impl Into<::buffa::EnumValue>, + ) -> Self { + self.code = Some(value.into()); + self + } + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::message`] to `Some(value)`, consuming and returning `self`. + pub fn with_message( + mut self, + value: impl Into<::buffa::alloc::string::String>, + ) -> Self { + self.message = Some(value.into()); + self + } +} +::buffa::impl_default_instance!(FailRequest); +impl ::buffa::MessageName for FailRequest { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "FailRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.FailRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailRequest"; +} +impl ::buffa::Message for FailRequest { + /// Returns the total encoded size in bytes. + /// + /// Accumulates in `u64` (which cannot overflow for in-memory + /// data) and saturates to `u32` at return, so a message whose + /// encoded size exceeds the 2 GiB protobuf limit yields a value + /// above [`::buffa::MAX_MESSAGE_BYTES`] that the encode entry + /// points reject, never a silently wrapped size. + #[allow(clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.code { + size += 1u64 + ::buffa::types::int32_encoded_len(v.to_i32()) as u64; + } + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.code { + ::buffa::types::put_int32_field(1u32, v.to_i32(), buf); + } + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(2u32, v, buf); + } + } + fn merge_field( + &mut self, + tag: ::buffa::encoding::Tag, + buf: &mut impl ::buffa::bytes::Buf, + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + #[allow(unused_imports)] + use ::buffa::bytes::Buf as _; + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::Varint, + )?; + self.code = ::core::option::Option::Some( + ::buffa::EnumValue::from(::buffa::types::decode_int32(buf)?), + ); + } + 2u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::types::merge_string( + self.message.get_or_insert_with(::buffa::alloc::string::String::new), + buf, + )?; + } + _ => { + ::buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; + } + } + ::core::result::Result::Ok(()) + } + fn clear(&mut self) { + self.code = ::core::option::Option::None; + self.message = ::core::option::Option::None; + } +} +impl ::buffa::json_helpers::ProtoElemJson for FailRequest { + fn serialize_proto_json( + v: &Self, + s: S, + ) -> ::core::result::Result { + ::serde::Serialize::serialize(v, s) + } + fn deserialize_proto_json<'de, D: ::serde::Deserializer<'de>>( + d: D, + ) -> ::core::result::Result { + ::deserialize(d) + } +} +#[doc(hidden)] +pub const __FAIL_REQUEST_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailRequest", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, + is_wkt: false, +}; +#[derive(Clone, PartialEq, Default)] +#[derive(::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct FailResponse { + /// Field 1: `message` + #[serde(rename = "message", skip_serializing_if = "::core::option::Option::is_none")] + pub message: ::core::option::Option<::buffa::alloc::string::String>, +} +impl ::core::fmt::Debug for FailResponse { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("FailResponse").field("message", &self.message).finish() + } +} +impl FailResponse { + /// Protobuf type URL for this message, for use with `Any::pack` and + /// `Any::unpack_if`. + /// + /// Format: `type.googleapis.com/` + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailResponse"; +} +impl FailResponse { + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::message`] to `Some(value)`, consuming and returning `self`. + pub fn with_message( + mut self, + value: impl Into<::buffa::alloc::string::String>, + ) -> Self { + self.message = Some(value.into()); + self + } +} +::buffa::impl_default_instance!(FailResponse); +impl ::buffa::MessageName for FailResponse { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "FailResponse"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.FailResponse"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailResponse"; +} +impl ::buffa::Message for FailResponse { + /// Returns the total encoded size in bytes. + /// + /// Accumulates in `u64` (which cannot overflow for in-memory + /// data) and saturates to `u32` at return, so a message whose + /// encoded size exceeds the 2 GiB protobuf limit yields a value + /// above [`::buffa::MAX_MESSAGE_BYTES`] that the encode entry + /// points reject, never a silently wrapped size. + #[allow(clippy::let_and_return)] + fn compute_size(&self, _cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u64; + if let Some(ref v) = self.message { + size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; + } + ::buffa::saturate_size(size) + } + fn write_to( + &self, + _cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::EncodeSink, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + if let Some(ref v) = self.message { + ::buffa::types::put_string_field(1u32, v, buf); + } + } + fn merge_field( + &mut self, + tag: ::buffa::encoding::Tag, + buf: &mut impl ::buffa::bytes::Buf, + ctx: ::buffa::DecodeContext<'_>, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + #[allow(unused_imports)] + use ::buffa::bytes::Buf as _; + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + match tag.field_number() { + 1u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::types::merge_string( + self.message.get_or_insert_with(::buffa::alloc::string::String::new), + buf, + )?; + } + _ => { + ::buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; + } + } + ::core::result::Result::Ok(()) + } + fn clear(&mut self) { + self.message = ::core::option::Option::None; + } +} +impl ::buffa::json_helpers::ProtoElemJson for FailResponse { + fn serialize_proto_json( + v: &Self, + s: S, + ) -> ::core::result::Result { + ::serde::Serialize::serialize(v, s) + } + fn deserialize_proto_json<'de, D: ::serde::Deserializer<'de>>( + d: D, + ) -> ::core::result::Result { + ::deserialize(d) + } +} +#[doc(hidden)] +pub const __FAIL_RESPONSE_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.FailResponse", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, + is_wkt: false, +}; diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.mod.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.mod.rs new file mode 100644 index 0000000000..b3f900076d --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.mod.rs @@ -0,0 +1,48 @@ +// @generated by buffa-codegen. DO NOT EDIT. + +include!("trogonai.grpc_nats_micro.v1.echo.rs"); +#[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception +)] +pub mod __buffa { + #[allow(unused_imports)] + use super::*; + pub mod view { + #[allow(unused_imports)] + use super::*; + include!("trogonai.grpc_nats_micro.v1.echo.__view.rs"); + } + /// Register this package's `Any` type entries and extension entries. + pub fn register_types(reg: &mut ::buffa::type_registry::TypeRegistry) { + reg.register_json_any(super::__SAY_REQUEST_JSON_ANY); + reg.register_json_any(super::__SAY_RESPONSE_JSON_ANY); + reg.register_json_any(super::__FAIL_REQUEST_JSON_ANY); + reg.register_json_any(super::__FAIL_RESPONSE_JSON_ANY); + } +} +#[doc(inline)] +pub use self::__buffa::view::SayRequestView; +#[doc(inline)] +pub use self::__buffa::view::SayRequestOwnedView; +#[doc(inline)] +pub use self::__buffa::view::SayResponseView; +#[doc(inline)] +pub use self::__buffa::view::SayResponseOwnedView; +#[doc(inline)] +pub use self::__buffa::view::FailRequestView; +#[doc(inline)] +pub use self::__buffa::view::FailRequestOwnedView; +#[doc(inline)] +pub use self::__buffa::view::FailResponseView; +#[doc(inline)] +pub use self::__buffa::view::FailResponseOwnedView; +#[doc(inline)] +pub use self::__buffa::register_types; diff --git a/rsworkspace/crates/platform/trogonai-proto/src/lib.rs b/rsworkspace/crates/platform/trogonai-proto/src/lib.rs index a5e93e8361..a8a020e8a9 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/lib.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/lib.rs @@ -15,7 +15,12 @@ reason = "buffa-codegen emits each message's view module beside the message it views, so the generated tree is cyclic by construction and is not edited here" ) )] -#[cfg(any(feature = "schedules", feature = "agents", feature = "decider"))] +#[cfg(any( + feature = "schedules", + feature = "agents", + feature = "decider", + feature = "grpc-nats-micro" +))] mod r#gen; #[cfg(any(feature = "schedules", feature = "agents"))] @@ -45,7 +50,22 @@ pub mod content { } } -#[cfg(any(feature = "schedules", feature = "agents", feature = "decider"))] +#[cfg(feature = "grpc-nats-micro")] +#[cfg_attr(dylint_lib = "trogon_lints", allow(inline_module_block))] +pub mod nats { + pub mod micro { + pub mod v1alpha1 { + pub use crate::r#gen::trogon::nats::micro::v1alpha1::*; + } + } +} + +#[cfg(any( + feature = "schedules", + feature = "agents", + feature = "decider", + feature = "grpc-nats-micro" +))] #[cfg_attr(dylint_lib = "trogon_lints", allow(inline_module_block))] pub mod google { #[cfg(any(feature = "schedules", feature = "agents"))] @@ -53,12 +73,20 @@ pub mod google { pub use crate::r#gen::google::r#type::*; } - #[cfg(feature = "decider")] + #[cfg(any(feature = "decider", feature = "grpc-nats-micro"))] pub mod rpc { pub use crate::r#gen::google::rpc::*; } } +#[cfg(feature = "grpc-nats-micro")] +#[cfg_attr(dylint_lib = "trogon_lints", allow(inline_module_block))] +pub mod grpc_nats_micro { + pub mod v1 { + pub use crate::r#gen::trogonai::grpc_nats_micro::v1::*; + } +} + /// Failure decoding a registered event payload to canonical JSON. #[cfg(any(feature = "schedules", feature = "agents"))] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]