From ee5beeff09ff39f7a652aee20d1fe20cb5683d6c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 00:42:23 -0400 Subject: [PATCH 01/14] feat(grpc-nats-micro): bind annotated protobuf services to NATS micro Signed-off-by: Yordis Prieto --- proto/trogonai/grpc_nats_micro/v1/echo.proto | 45 ++ rsworkspace/Cargo.lock | 17 + rsworkspace/Cargo.toml | 1 + .../platform/grpc-nats-micro/Cargo.toml | 25 + .../platform/grpc-nats-micro/src/binding.rs | 102 +++ .../platform/grpc-nats-micro/src/client.rs | 65 ++ .../platform/grpc-nats-micro/src/constants.rs | 17 + .../grpc-nats-micro/src/content_type.rs | 112 +++ .../platform/grpc-nats-micro/src/lib.rs | 22 + .../platform/grpc-nats-micro/src/server.rs | 187 +++++ .../grpc-nats-micro/src/status_codec.rs | 119 +++ .../grpc-nats-micro/tests/echo_conformance.rs | 278 +++++++ .../crates/platform/trogonai-proto/Cargo.toml | 4 + .../platform/trogonai-proto/src/gen/mod.rs | 29 + ...trogonai.grpc_nats_micro.v1.echo.__view.rs | 750 ++++++++++++++++++ .../gen/trogonai.grpc_nats_micro.v1.echo.rs | 400 ++++++++++ .../gen/trogonai.grpc_nats_micro.v1.mod.rs | 43 + .../crates/platform/trogonai-proto/src/lib.rs | 34 +- 18 files changed, 2247 insertions(+), 3 deletions(-) create mode 100644 proto/trogonai/grpc_nats_micro/v1/echo.proto create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/client.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/constants.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/server.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs create mode 100644 rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.__view.rs create mode 100644 rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.rs create mode 100644 rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.mod.rs 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..ebf613e86c --- /dev/null +++ b/proto/trogonai/grpc_nats_micro/v1/echo.proto @@ -0,0 +1,45 @@ +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"; + +// Echo 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 Echo { + option (trogon.nats.micro.v1alpha1.service) = { + version: "1" + }; + + // Say returns the request message unchanged. + rpc Say(EchoRequest) returns (EchoReply); + + // 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 (EchoReply) { + option (trogon.nats.micro.v1alpha1.method) = { + metadata: { key: "always-faults" value: "true" } + }; + } +} + +message EchoRequest { + string message = 1; +} + +message EchoReply { + string message = 1; +} + +message FailRequest { + // Canonical status code the handler should fault with. + google.rpc.Code code = 1; + string message = 2; +} diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 1df6615828..1c6a5ff921 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -2789,6 +2789,23 @@ dependencies = [ "subtle", ] +[[package]] +name = "grpc-nats-micro" +version = "0.1.0" +dependencies = [ + "async-nats", + "buffa", + "bytes", + "futures-util", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "trogon-nats", + "trogonai-proto", +] + [[package]] name = "h2" version = "0.4.14" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 060a02e97c..f29f6b988b 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" } 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..523756d2f5 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -0,0 +1,25 @@ +[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 } +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] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "time"] } 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..cf352dcdf1 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -0,0 +1,102 @@ +//! Binding descriptors: subject derivation per ADR 0016 §2 +//! (`..`). + +/// A NATS subject derived from a subject prefix, service name, and method +/// name, per ADR 0016 §2. Always constructed through [`EndpointSubject::new`] +/// so the derivation rule cannot drift out of sync at a call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointSubject(String); + +impl EndpointSubject { + pub fn new(subject_prefix: &str, service_name: &str, method_name: &str) -> Self { + Self(format!("{subject_prefix}.{service_name}.{method_name}")) + } + + 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.0) + } +} + +/// 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: String, + subject: EndpointSubject, +} + +impl EndpointBinding { + pub fn new(subject_prefix: &str, service_name: &str, method_name: impl Into) -> Self { + let method_name = method_name.into(); + let subject = EndpointSubject::new(subject_prefix, service_name, &method_name); + Self { method_name, subject } + } + + pub fn method_name(&self) -> &str { + &self.method_name + } + + pub fn subject(&self) -> &EndpointSubject { + &self.subject + } +} + +/// 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: String, + version: String, + description: Option, + subject_prefix: String, + endpoints: Vec, +} + +impl ServiceBinding { + pub fn new(name: impl Into, version: impl Into, subject_prefix: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + description: None, + subject_prefix: subject_prefix.into(), + 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 + } + + /// Register an `rpc` method as a micro endpoint, deriving its subject + /// from this binding's subject prefix and service name. + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + pub fn with_method(mut self, method_name: impl Into) -> Self { + self.endpoints + .push(EndpointBinding::new(&self.subject_prefix, &self.name, method_name)); + self + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn endpoints(&self) -> &[EndpointBinding] { + &self.endpoints + } +} 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..3bce1f4d7d --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs @@ -0,0 +1,65 @@ +//! 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}; + +#[derive(Debug, Error)] +pub enum RequestError { + #[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: {error}")] + Transport { subject: String, error: String }, + #[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, + 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: error.to_string(), + })?; + + decode_reply(response.headers.as_ref(), &response.payload, content_type).map_err(RequestError::from) +} 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..f79ef57d5b --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -0,0 +1,112 @@ +use buffa::Message; +use thiserror::Error; +use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; + +use crate::constants::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTOBUF}; + +/// 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 { + use trogonai_proto::nats::micro::v1alpha1::ContentType as ProtoContentType; + + 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, + } + } + + /// 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<&str>) -> Result { + let allowed = Self::allowed(policy); + match header { + Some(CONTENT_TYPE_PROTOBUF) => match allowed { + Allowed::Either | Allowed::Only(Self::Protobuf) => Ok(Self::Protobuf), + Allowed::Only(Self::Json) => Err(NegotiationError::NotAllowed { + requested: Self::Protobuf, + }), + }, + Some(CONTENT_TYPE_JSON) => match allowed { + Allowed::Either | Allowed::Only(Self::Json) => Ok(Self::Json), + Allowed::Only(Self::Protobuf) => Err(NegotiationError::NotAllowed { requested: Self::Json }), + }, + Some(other) => Err(NegotiationError::UnknownContentType { + value: other.to_string(), + }), + 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: {value}")] + UnknownContentType { value: String }, +} + +#[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), +} 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..3e9170bf03 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -0,0 +1,22 @@ +//! 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 server; +pub mod status_codec; + +pub use binding::{EndpointBinding, EndpointSubject, ServiceBinding}; +pub use content_type::ContentType; +pub use server::{EndpointHandler, ServeError, serve}; +pub use status_codec::{EncodedReply, Outcome, ReplyError, ServiceError}; 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..0e43bd72e5 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -0,0 +1,187 @@ +//! 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 buffa::Enumeration as _; +use futures_util::StreamExt as _; +use thiserror::Error; +use trogonai_proto::google::rpc::{Code, Status}; +use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; + +use crate::binding::ServiceBinding; +use crate::constants::HEADER_CONTENT_TYPE; +use crate::content_type::ContentType; +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 [`Status`] 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, Status>> + Send + 'a>>; +} + +#[derive(Debug, Error)] +pub enum ServeError { + #[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 { + let mut builder = client.service_builder(); + if let Some(description) = binding.description() { + builder = builder.description(description); + } + let service = builder + .start(binding.name(), binding.version()) + .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(); + let micro_endpoint = service + .endpoint(subject.clone()) + .await + .map_err(|source| ServeError::Endpoint { + subject: subject.clone(), + source, + })?; + + tokio::spawn(run_endpoint( + client.clone(), + micro_endpoint, + content_type_policy.clone(), + handler, + )); + } + + Ok(service) +} + +async fn run_endpoint( + client: Client, + mut micro_endpoint: async_nats::service::endpoint::Endpoint, + content_type_policy: Arc, + handler: Box, +) { + while let Some(request) = micro_endpoint.next().await { + dispatch(&client, &request, &content_type_policy, handler.as_ref()).await; + } +} + +async fn dispatch( + client: &Client, + request: &async_nats::service::Request, + content_type_policy: &ServiceOptions, + handler: &dyn EndpointHandler, +) { + let header_value = request + .message + .headers + .as_ref() + .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) + .map(|value| value.as_str()); + + let content_type = match ContentType::negotiate(content_type_policy, header_value) { + Ok(content_type) => content_type, + Err(error) => { + let status = Status { + code: Code::INVALID_ARGUMENT.to_i32(), + message: error.to_string(), + details: Vec::new(), + }; + reply_error(client, request, status, ContentType::Protobuf).await; + return; + } + }; + + match handler.handle(&request.message.payload, content_type).await { + Ok(body) => reply_success(request, body, content_type).await, + Err(status) => reply_error(client, request, status, content_type).await, + } +} + +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()); + if let Err(source) = request + .respond_with_headers(Ok(bytes::Bytes::from(body)), headers) + .await + { + tracing::warn!(error = %source, "grpc-nats-micro: failed to publish success reply"); + } +} + +/// 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: &Client, + request: &async_nats::service::Request, + status: Status, + content_type: ContentType, +) { + let Some(reply) = request.message.reply.clone() else { + tracing::warn!("grpc-nats-micro: request had no reply subject; dropping error reply"); + return; + }; + let encoded = match status_codec::encode_reply(Outcome::Error(status), content_type) { + Ok(encoded) => encoded, + Err(error) => { + tracing::warn!(error = %error, "grpc-nats-micro: failed to encode error reply"); + return; + } + }; + let mut headers = encoded.headers; + headers.insert(HEADER_CONTENT_TYPE, content_type.header_value()); + if let Err(source) = client.publish_with_headers(reply, headers, encoded.body).await { + tracing::warn!(error = %source, "grpc-nats-micro: failed to publish error reply"); + } +} 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..d173101d86 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -0,0 +1,119 @@ +//! 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 async_nats::HeaderMap; +use buffa::Enumeration as _; +use bytes::Bytes; +use thiserror::Error; +use trogonai_proto::google::rpc::{Code, Status}; + +use crate::constants::{HEADER_ERROR, HEADER_ERROR_CODE}; +use crate::content_type::{ContentType, DecodeError, EncodeError}; + +/// A successful reply body, or a fault reported on the micro error channel. +pub enum Outcome { + Success(Bytes), + Error(Status), +} + +/// 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. +/// +/// An `Outcome::Error` whose `code` is `OK` (0) is a programmer error: this +/// coerces it to `INTERNAL` rather than emit an error reply with no error +/// code, since the ADR requires the error code space to never contain `OK` +/// on an error reply. +pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result { + match outcome { + Outcome::Success(body) => Ok(EncodedReply { + headers: HeaderMap::new(), + body, + }), + Outcome::Error(mut status) => { + if status.code == Code::OK.to_i32() { + status.code = Code::INTERNAL.to_i32(); + } + let body = content_type.encode(&status)?; + let mut headers = HeaderMap::new(); + headers.insert(HEADER_ERROR, status.message.as_str()); + headers.insert(HEADER_ERROR_CODE, status.code.to_string().as_str()); + Ok(EncodedReply { + headers, + body: Bytes::from(body), + }) + } + } +} + +/// A decoded micro service error: the headers are authoritative for `code` +/// on any disagreement with the body per ADR 0016 §3. +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceError { + pub code: i32, + pub message: String, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "nats micro service error (code {}): {}", self.code, self.message) + } +} + +impl std::error::Error for ServiceError {} + +impl ServiceError { + fn from_status(header_code: i32, status: Status) -> Self { + Self { + code: header_code, + message: status.message, + } + } +} + +/// 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 [`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`. +pub fn decode_reply( + headers: Option<&HeaderMap>, + body: &[u8], + content_type: ContentType, +) -> Result +where + Resp: buffa::Message + serde::de::DeserializeOwned, +{ + let error_code = headers.and_then(|headers| headers.get(HEADER_ERROR_CODE)); + + match error_code { + Some(code_header) => { + let header_code: i32 = code_header + .as_str() + .parse() + .map_err(|_| ReplyError::InvalidErrorCodeHeader { + value: code_header.as_str().to_string(), + })?; + let status: Status = content_type.decode(body).map_err(ReplyError::Decode)?; + Err(ReplyError::Service(ServiceError::from_status(header_code, status))) + } + None => content_type.decode(body).map_err(ReplyError::Decode), + } +} + +#[derive(Debug, Error)] +pub enum ReplyError { + #[error("invalid {HEADER_ERROR_CODE} header value: {value}")] + InvalidErrorCodeHeader { value: String }, + #[error("failed to decode reply payload")] + Decode(#[source] DecodeError), + #[error(transparent)] + Service(#[from] ServiceError), +} 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..6d5ec14aab --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -0,0 +1,278 @@ +//! ADR 0016 conformance: an Echo/Fail service registered through +//! [`grpc_nats_micro::serve`] against a real `nats-server` process. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::future::Future; +use std::net::TcpListener; +use std::pin::Pin; +use std::time::Duration; + +use async_nats::HeaderMap; +use async_nats::service::Service; +use buffa::Enumeration as _; +use grpc_nats_micro::constants::HEADER_ERROR_CODE; +use grpc_nats_micro::{ContentType, EndpointHandler, ServiceBinding}; +use tokio::process::{Child, Command}; +use trogonai_proto::google::rpc::{Code, Status}; +use trogonai_proto::grpc_nats_micro::v1::{EchoReply, EchoRequest, FailRequest}; + +const SUBJECT_PREFIX: &str = "echo.v1"; +const SERVICE_NAME: &str = "EchoService"; +const SERVICE_VERSION: &str = "0.1.0"; +const SAY_METHOD: &str = "Say"; +const FAIL_METHOD: &str = "Fail"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +struct NatsServerProcess { + child: Child, + port: u16, +} + +impl NatsServerProcess { + async fn spawn() -> Self { + let port = free_port(); + let child = Command::new("nats-server") + .args(["-p", &port.to_string(), "-a", "127.0.0.1"]) + .kill_on_drop(true) + .spawn() + .expect("spawn nats-server; is it on PATH?"); + let process = Self { child, port }; + process.wait_until_ready().await; + process + } + + async fn wait_until_ready(&self) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if async_nats::connect(self.url()).await.is_ok() { + return; + } + if tokio::time::Instant::now() >= deadline { + panic!("nats-server did not become ready on port {}", self.port); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + fn url(&self) -> String { + format!("127.0.0.1:{}", self.port) + } +} + +impl Drop for NatsServerProcess { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind ephemeral port") + .local_addr() + .expect("local addr") + .port() +} + +struct SayHandler; + +impl EndpointHandler for SayHandler { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + content_type: ContentType, + ) -> Pin, Status>> + Send + 'a>> { + Box::pin(async move { + let request: EchoRequest = content_type.decode(request_bytes).map_err(|error| Status { + code: Code::INVALID_ARGUMENT.to_i32(), + message: error.to_string(), + details: Vec::new(), + })?; + let reply = EchoReply { + message: request.message, + }; + content_type.encode(&reply).map_err(|error| Status { + code: Code::INTERNAL.to_i32(), + message: error.to_string(), + details: Vec::new(), + }) + }) + } +} + +struct FailHandler; + +impl EndpointHandler for FailHandler { + fn handle<'a>( + &'a self, + request_bytes: &'a [u8], + content_type: ContentType, + ) -> Pin, Status>> + Send + 'a>> { + Box::pin(async move { + let request: FailRequest = content_type.decode(request_bytes).map_err(|error| Status { + code: Code::INVALID_ARGUMENT.to_i32(), + message: error.to_string(), + details: Vec::new(), + })?; + let code = request.code.and_then(|value| value.as_known()).unwrap_or(Code::UNKNOWN); + Err(Status { + code: code.to_i32(), + message: request.message.unwrap_or_default(), + details: Vec::new(), + }) + }) + } +} + +fn echo_service_binding() -> ServiceBinding { + ServiceBinding::new(SERVICE_NAME, SERVICE_VERSION, SUBJECT_PREFIX) + .with_method(SAY_METHOD) + .with_method(FAIL_METHOD) +} + +/// Keeps the spawned `nats-server` process, 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: NatsServerProcess, + client: async_nats::Client, + binding: ServiceBinding, + _service: Service, +} + +async fn start_fixture() -> EchoFixture { + let server = NatsServerProcess::spawn().await; + let client = async_nats::connect(server.url()).await.expect("connect to nats-server"); + + let binding = echo_service_binding(); + let handlers: Vec> = vec![Box::new(SayHandler), Box::new(FailHandler)]; + + let service = grpc_nats_micro::serve( + &client, + &binding, + trogonai_proto::nats::micro::v1alpha1::ServiceOptions::default(), + 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() == SAY_METHOD) + .expect("Say endpoint registered"); + + let request = EchoRequest { + message: Some(message.to_string()), + }; + let body = content_type.encode(&request).expect("encode EchoRequest"); + 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, + fixture.client.request_with_headers(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() == 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, + fixture.client.request_with_headers(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: EchoReply = content_type.decode(&response.payload).expect("decode EchoReply"); + 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"); +} + +#[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; +} 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..8f2d1a48c8 --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.__view.rs @@ -0,0 +1,750 @@ +// @generated by buffa-codegen. DO NOT EDIT. +// source: trogonai/grpc_nats_micro/v1/echo.proto + +#[derive(Clone, Debug, Default)] +pub struct EchoRequestView<'a> { + /// Field 1: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for EchoRequestView<'a> { + type Owned = super::super::EchoRequest; + 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::EchoRequest { + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for EchoRequestView<'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 EchoRequestView<'__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 EchoRequestView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "EchoRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest"; +} +::buffa::impl_default_view_instance!(EchoRequestView); +::buffa::impl_view_reborrow!(EchoRequestView); +/** Self-contained, `'static` owned view of a `EchoRequest` message. + + Wraps [`::buffa::OwnedView`]`<`[`EchoRequestView`]`<'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 [`EchoRequestView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct EchoRequestOwnedView(::buffa::OwnedView>); +impl EchoRequestOwnedView { + /// 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( + EchoRequestOwnedView(::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( + EchoRequestOwnedView(::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::EchoRequest, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + EchoRequestOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`EchoRequestView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &EchoRequestView<'_> { + 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::EchoRequest { + 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 EchoRequestOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + EchoRequestOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: EchoRequestOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for EchoRequestOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::EchoRequest { + type View<'a> = EchoRequestView<'a>; + type ViewHandle = EchoRequestOwnedView; +} +impl ::serde::Serialize for EchoRequestOwnedView { + 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 EchoReplyView<'a> { + /// Field 1: `message` + pub message: ::core::option::Option<&'a str>, +} +impl<'a> ::buffa::MessageView<'a> for EchoReplyView<'a> { + type Owned = super::super::EchoReply; + 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::EchoReply { + message: self.message.map(|s| s.to_string()), + ..::core::default::Default::default() + }) + } +} +impl<'a> ::buffa::ViewEncode<'a> for EchoReplyView<'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 EchoReplyView<'__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 EchoReplyView<'a> { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "EchoReply"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoReply"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply"; +} +::buffa::impl_default_view_instance!(EchoReplyView); +::buffa::impl_view_reborrow!(EchoReplyView); +/** Self-contained, `'static` owned view of a `EchoReply` message. + + Wraps [`::buffa::OwnedView`]`<`[`EchoReplyView`]`<'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 [`EchoReplyView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct EchoReplyOwnedView(::buffa::OwnedView>); +impl EchoReplyOwnedView { + /// 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( + EchoReplyOwnedView(::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( + EchoReplyOwnedView(::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::EchoReply, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + EchoReplyOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`EchoReplyView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &EchoReplyView<'_> { + 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::EchoReply { + 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 EchoReplyOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + EchoReplyOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: EchoReplyOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for EchoReplyOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::EchoReply { + type View<'a> = EchoReplyView<'a>; + type ViewHandle = EchoReplyOwnedView; +} +impl ::serde::Serialize for EchoReplyOwnedView { + 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) + } +} 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..586b55485d --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.echo.rs @@ -0,0 +1,400 @@ +// @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 EchoRequest { + /// 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 EchoRequest { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("EchoRequest").field("message", &self.message).finish() + } +} +impl EchoRequest { + /// 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.EchoRequest"; +} +impl EchoRequest { + #[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!(EchoRequest); +impl ::buffa::MessageName for EchoRequest { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "EchoRequest"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoRequest"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest"; +} +impl ::buffa::Message for EchoRequest { + /// 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 EchoRequest { + 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 __ECHO_REQUEST_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest", + 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 EchoReply { + /// 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 EchoReply { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("EchoReply").field("message", &self.message).finish() + } +} +impl EchoReply { + /// 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.EchoReply"; +} +impl EchoReply { + #[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!(EchoReply); +impl ::buffa::MessageName for EchoReply { + const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; + const NAME: &'static str = "EchoReply"; + const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoReply"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply"; +} +impl ::buffa::Message for EchoReply { + /// 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 EchoReply { + 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 __ECHO_REPLY_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply", + 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, +}; 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..6abfe8b8e7 --- /dev/null +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.grpc_nats_micro.v1.mod.rs @@ -0,0 +1,43 @@ +// @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::__ECHO_REQUEST_JSON_ANY); + reg.register_json_any(super::__ECHO_REPLY_JSON_ANY); + reg.register_json_any(super::__FAIL_REQUEST_JSON_ANY); + } +} +#[doc(inline)] +pub use self::__buffa::view::EchoRequestView; +#[doc(inline)] +pub use self::__buffa::view::EchoRequestOwnedView; +#[doc(inline)] +pub use self::__buffa::view::EchoReplyView; +#[doc(inline)] +pub use self::__buffa::view::EchoReplyOwnedView; +#[doc(inline)] +pub use self::__buffa::view::FailRequestView; +#[doc(inline)] +pub use self::__buffa::view::FailRequestOwnedView; +#[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)] From f06bda6857376d2e27fb3b0370a521cbd6163a84 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 00:54:34 -0400 Subject: [PATCH 02/14] fix(grpc-nats-micro): keep transport failures matchable and the schema lint-clean Signed-off-by: Yordis Prieto --- proto/trogonai/grpc_nats_micro/v1/echo.proto | 29 +- .../platform/grpc-nats-micro/src/client.rs | 21 +- .../grpc-nats-micro/src/content_type.rs | 4 +- .../grpc-nats-micro/src/status_codec.rs | 11 +- .../grpc-nats-micro/tests/echo_conformance.rs | 12 +- ...trogonai.grpc_nats_micro.v1.echo.__view.rs | 397 ++++++++++++++---- .../gen/trogonai.grpc_nats_micro.v1.echo.rs | 187 +++++++-- .../gen/trogonai.grpc_nats_micro.v1.mod.rs | 17 +- 8 files changed, 523 insertions(+), 155 deletions(-) diff --git a/proto/trogonai/grpc_nats_micro/v1/echo.proto b/proto/trogonai/grpc_nats_micro/v1/echo.proto index ebf613e86c..e209c9788e 100644 --- a/proto/trogonai/grpc_nats_micro/v1/echo.proto +++ b/proto/trogonai/grpc_nats_micro/v1/echo.proto @@ -10,31 +10,32 @@ import "google/rpc/code.proto"; import "google/rpc/status.proto"; import "trogon/nats/micro/v1alpha1/options.proto"; -// Echo 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 Echo { - option (trogon.nats.micro.v1alpha1.service) = { - version: "1" - }; +// 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"}; // Say returns the request message unchanged. - rpc Say(EchoRequest) returns (EchoReply); + 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 (EchoReply) { + rpc Fail(FailRequest) returns (FailResponse) { option (trogon.nats.micro.v1alpha1.method) = { - metadata: { key: "always-faults" value: "true" } + metadata: { + key: "always-faults" + value: "true" + } }; } } -message EchoRequest { +message SayRequest { string message = 1; } -message EchoReply { +message SayResponse { string message = 1; } @@ -43,3 +44,7 @@ message FailRequest { google.rpc.Code code = 1; string message = 2; } + +message FailResponse { + string message = 1; +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs index 3bce1f4d7d..edd4846dcd 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs @@ -13,14 +13,24 @@ 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 { +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: {error}")] - Transport { subject: String, error: String }, + #[error("NATS request to {subject} failed")] + Transport { + subject: String, + #[source] + error: E, + }, #[error(transparent)] Reply(#[from] ReplyError), } @@ -36,9 +46,10 @@ pub async fn request( content_type: ContentType, request: &Req, timeout: Duration, -) -> Result +) -> Result> where N: RequestClient, + N::RequestError: 'static, Req: buffa::Message + serde::Serialize, Resp: buffa::Message + serde::de::DeserializeOwned, { @@ -58,7 +69,7 @@ where })? .map_err(|error| RequestError::Transport { subject: subject.clone(), - error: error.to_string(), + error, })?; decode_reply(response.headers.as_ref(), &response.payload, content_type).map_err(RequestError::from) diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs index f79ef57d5b..cb4f5cb816 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -1,6 +1,6 @@ use buffa::Message; use thiserror::Error; -use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; +use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, ServiceOptions}; use crate::constants::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTOBUF}; @@ -15,8 +15,6 @@ impl ContentType { /// The set of `Content-Type` values a [`ServiceOptions::content_type`] /// restriction allows on the wire. fn allowed(policy: &ServiceOptions) -> Allowed { - use trogonai_proto::nats::micro::v1alpha1::ContentType as ProtoContentType; - match policy.content_type.as_known() { Some(ProtoContentType::CONTENT_TYPE_PROTOBUF) => Allowed::Only(Self::Protobuf), Some(ProtoContentType::CONTENT_TYPE_JSON) => Allowed::Only(Self::Json), diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index d173101d86..5786e807fa 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -54,20 +54,13 @@ pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result) -> std::fmt::Result { - write!(f, "nats micro service error (code {}): {}", self.code, self.message) - } -} - -impl std::error::Error for ServiceError {} - impl ServiceError { fn from_status(header_code: i32, status: Status) -> Self { Self { diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index 6d5ec14aab..bf52a85247 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -14,7 +14,7 @@ use grpc_nats_micro::constants::HEADER_ERROR_CODE; use grpc_nats_micro::{ContentType, EndpointHandler, ServiceBinding}; use tokio::process::{Child, Command}; use trogonai_proto::google::rpc::{Code, Status}; -use trogonai_proto::grpc_nats_micro::v1::{EchoReply, EchoRequest, FailRequest}; +use trogonai_proto::grpc_nats_micro::v1::{FailRequest, SayRequest, SayResponse}; const SUBJECT_PREFIX: &str = "echo.v1"; const SERVICE_NAME: &str = "EchoService"; @@ -82,12 +82,12 @@ impl EndpointHandler for SayHandler { content_type: ContentType, ) -> Pin, Status>> + Send + 'a>> { Box::pin(async move { - let request: EchoRequest = content_type.decode(request_bytes).map_err(|error| Status { + let request: SayRequest = content_type.decode(request_bytes).map_err(|error| Status { code: Code::INVALID_ARGUMENT.to_i32(), message: error.to_string(), details: Vec::new(), })?; - let reply = EchoReply { + let reply = SayResponse { message: request.message, }; content_type.encode(&reply).map_err(|error| Status { @@ -172,10 +172,10 @@ async fn say(fixture: &EchoFixture, content_type: ContentType, message: &str) -> .find(|endpoint| endpoint.method_name() == SAY_METHOD) .expect("Say endpoint registered"); - let request = EchoRequest { + let request = SayRequest { message: Some(message.to_string()), }; - let body = content_type.encode(&request).expect("encode EchoRequest"); + let body = content_type.encode(&request).expect("encode SayRequest"); let mut headers = HeaderMap::new(); headers.insert( grpc_nats_micro::constants::HEADER_CONTENT_TYPE, @@ -234,7 +234,7 @@ async fn assert_say_round_trips(content_type: ContentType) { .is_none(), "successful Say reply must not carry {HEADER_ERROR_CODE}" ); - let reply: EchoReply = content_type.decode(&response.payload).expect("decode EchoReply"); + let reply: SayResponse = content_type.decode(&response.payload).expect("decode SayResponse"); assert_eq!(reply.message, Some("hello".to_string())); } 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 index 8f2d1a48c8..7a3463ad65 100644 --- 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 @@ -2,12 +2,12 @@ // source: trogonai/grpc_nats_micro/v1/echo.proto #[derive(Clone, Debug, Default)] -pub struct EchoRequestView<'a> { +pub struct SayRequestView<'a> { /// Field 1: `message` pub message: ::core::option::Option<&'a str>, } -impl<'a> ::buffa::MessageView<'a> for EchoRequestView<'a> { - type Owned = super::super::EchoRequest; +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( @@ -49,24 +49,24 @@ impl<'a> ::buffa::MessageView<'a> for EchoRequestView<'a> { } fn to_owned_message( &self, - ) -> ::core::result::Result { + ) -> ::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 { + ) -> ::core::result::Result { #[allow(unused_imports)] use ::buffa::alloc::string::ToString as _; let _ = __buffa_src; - ::core::result::Result::Ok(super::super::EchoRequest { + ::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 EchoRequestView<'a> { +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)] @@ -101,7 +101,7 @@ impl<'a> ::buffa::ViewEncode<'a> for EchoRequestView<'a> { /// 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 EchoRequestView<'__a> { +impl<'__a> ::serde::Serialize for SayRequestView<'__a> { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, @@ -114,22 +114,22 @@ impl<'__a> ::serde::Serialize for EchoRequestView<'__a> { __map.end() } } -impl<'a> ::buffa::MessageName for EchoRequestView<'a> { +impl<'a> ::buffa::MessageName for SayRequestView<'a> { const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; - const NAME: &'static str = "EchoRequest"; - const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoRequest"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest"; + 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!(EchoRequestView); -::buffa::impl_view_reborrow!(EchoRequestView); -/** Self-contained, `'static` owned view of a `EchoRequest` message. +::buffa::impl_default_view_instance!(SayRequestView); +::buffa::impl_view_reborrow!(SayRequestView); +/** Self-contained, `'static` owned view of a `SayRequest` message. - Wraps [`::buffa::OwnedView`]`<`[`EchoRequestView`]`<'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. + 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 [`EchoRequestView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ + 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 EchoRequestOwnedView(::buffa::OwnedView>); -impl EchoRequestOwnedView { +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 @@ -143,7 +143,7 @@ impl EchoRequestOwnedView { bytes: ::buffa::bytes::Bytes, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoRequestOwnedView(::buffa::OwnedView::decode(bytes)?), + SayRequestOwnedView(::buffa::OwnedView::decode(bytes)?), ) } /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, @@ -158,7 +158,7 @@ impl EchoRequestOwnedView { opts: &::buffa::DecodeOptions, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoRequestOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + SayRequestOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), ) } /// Build from an owned message via an encode → decode round-trip. @@ -170,15 +170,15 @@ impl EchoRequestOwnedView { /// 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::EchoRequest, + msg: &super::super::SayRequest, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoRequestOwnedView(::buffa::OwnedView::from_owned(msg)?), + SayRequestOwnedView(::buffa::OwnedView::from_owned(msg)?), ) } - /// Borrow the full [`EchoRequestView`] with its lifetime tied to `&self`. + /// Borrow the full [`SayRequestView`] with its lifetime tied to `&self`. #[must_use] - pub fn view(&self) -> &EchoRequestView<'_> { + pub fn view(&self) -> &SayRequestView<'_> { self.0.reborrow() } /// Convert to the owned message type. @@ -189,7 +189,7 @@ impl EchoRequestOwnedView { /// whose contract also governs handles converted from a raw /// [`::buffa::OwnedView`]. #[must_use] - pub fn to_owned_message(&self) -> super::super::EchoRequest { + pub fn to_owned_message(&self) -> super::super::SayRequest { self.0.to_owned_message() } /// The underlying bytes buffer. @@ -208,29 +208,29 @@ impl EchoRequestOwnedView { self.0.reborrow().message } } -impl ::core::convert::From<::buffa::OwnedView>> -for EchoRequestOwnedView { - fn from(inner: ::buffa::OwnedView>) -> Self { - EchoRequestOwnedView(inner) +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: EchoRequestOwnedView) -> Self { +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: SayRequestOwnedView) -> Self { wrapper.0 } } -impl ::core::convert::AsRef<::buffa::OwnedView>> -for EchoRequestOwnedView { - fn as_ref(&self) -> &::buffa::OwnedView> { +impl ::core::convert::AsRef<::buffa::OwnedView>> +for SayRequestOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { &self.0 } } -impl ::buffa::HasMessageView for super::super::EchoRequest { - type View<'a> = EchoRequestView<'a>; - type ViewHandle = EchoRequestOwnedView; +impl ::buffa::HasMessageView for super::super::SayRequest { + type View<'a> = SayRequestView<'a>; + type ViewHandle = SayRequestOwnedView; } -impl ::serde::Serialize for EchoRequestOwnedView { +impl ::serde::Serialize for SayRequestOwnedView { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, @@ -239,12 +239,12 @@ impl ::serde::Serialize for EchoRequestOwnedView { } } #[derive(Clone, Debug, Default)] -pub struct EchoReplyView<'a> { +pub struct SayResponseView<'a> { /// Field 1: `message` pub message: ::core::option::Option<&'a str>, } -impl<'a> ::buffa::MessageView<'a> for EchoReplyView<'a> { - type Owned = super::super::EchoReply; +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( @@ -286,24 +286,24 @@ impl<'a> ::buffa::MessageView<'a> for EchoReplyView<'a> { } fn to_owned_message( &self, - ) -> ::core::result::Result { + ) -> ::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 { + ) -> ::core::result::Result { #[allow(unused_imports)] use ::buffa::alloc::string::ToString as _; let _ = __buffa_src; - ::core::result::Result::Ok(super::super::EchoReply { + ::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 EchoReplyView<'a> { +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)] @@ -338,7 +338,7 @@ impl<'a> ::buffa::ViewEncode<'a> for EchoReplyView<'a> { /// 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 EchoReplyView<'__a> { +impl<'__a> ::serde::Serialize for SayResponseView<'__a> { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, @@ -351,22 +351,22 @@ impl<'__a> ::serde::Serialize for EchoReplyView<'__a> { __map.end() } } -impl<'a> ::buffa::MessageName for EchoReplyView<'a> { +impl<'a> ::buffa::MessageName for SayResponseView<'a> { const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; - const NAME: &'static str = "EchoReply"; - const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoReply"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply"; + 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!(EchoReplyView); -::buffa::impl_view_reborrow!(EchoReplyView); -/** Self-contained, `'static` owned view of a `EchoReply` message. +::buffa::impl_default_view_instance!(SayResponseView); +::buffa::impl_view_reborrow!(SayResponseView); +/** Self-contained, `'static` owned view of a `SayResponse` message. - Wraps [`::buffa::OwnedView`]`<`[`EchoReplyView`]`<'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. + 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 [`EchoReplyView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ + 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 EchoReplyOwnedView(::buffa::OwnedView>); -impl EchoReplyOwnedView { +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 @@ -380,7 +380,7 @@ impl EchoReplyOwnedView { bytes: ::buffa::bytes::Bytes, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoReplyOwnedView(::buffa::OwnedView::decode(bytes)?), + SayResponseOwnedView(::buffa::OwnedView::decode(bytes)?), ) } /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, @@ -395,7 +395,7 @@ impl EchoReplyOwnedView { opts: &::buffa::DecodeOptions, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoReplyOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + SayResponseOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), ) } /// Build from an owned message via an encode → decode round-trip. @@ -407,15 +407,15 @@ impl EchoReplyOwnedView { /// 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::EchoReply, + msg: &super::super::SayResponse, ) -> ::core::result::Result { ::core::result::Result::Ok( - EchoReplyOwnedView(::buffa::OwnedView::from_owned(msg)?), + SayResponseOwnedView(::buffa::OwnedView::from_owned(msg)?), ) } - /// Borrow the full [`EchoReplyView`] with its lifetime tied to `&self`. + /// Borrow the full [`SayResponseView`] with its lifetime tied to `&self`. #[must_use] - pub fn view(&self) -> &EchoReplyView<'_> { + pub fn view(&self) -> &SayResponseView<'_> { self.0.reborrow() } /// Convert to the owned message type. @@ -426,7 +426,7 @@ impl EchoReplyOwnedView { /// whose contract also governs handles converted from a raw /// [`::buffa::OwnedView`]. #[must_use] - pub fn to_owned_message(&self) -> super::super::EchoReply { + pub fn to_owned_message(&self) -> super::super::SayResponse { self.0.to_owned_message() } /// The underlying bytes buffer. @@ -445,29 +445,29 @@ impl EchoReplyOwnedView { self.0.reborrow().message } } -impl ::core::convert::From<::buffa::OwnedView>> -for EchoReplyOwnedView { - fn from(inner: ::buffa::OwnedView>) -> Self { - EchoReplyOwnedView(inner) +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: EchoReplyOwnedView) -> Self { +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: SayResponseOwnedView) -> Self { wrapper.0 } } -impl ::core::convert::AsRef<::buffa::OwnedView>> -for EchoReplyOwnedView { - fn as_ref(&self) -> &::buffa::OwnedView> { +impl ::core::convert::AsRef<::buffa::OwnedView>> +for SayResponseOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { &self.0 } } -impl ::buffa::HasMessageView for super::super::EchoReply { - type View<'a> = EchoReplyView<'a>; - type ViewHandle = EchoReplyOwnedView; +impl ::buffa::HasMessageView for super::super::SayResponse { + type View<'a> = SayResponseView<'a>; + type ViewHandle = SayResponseOwnedView; } -impl ::serde::Serialize for EchoReplyOwnedView { +impl ::serde::Serialize for SayResponseOwnedView { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, @@ -748,3 +748,240 @@ impl ::serde::Serialize for FailRequestOwnedView { ::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 index 586b55485d..4f2e7590f4 100644 --- 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 @@ -4,24 +4,24 @@ #[derive(Clone, PartialEq, Default)] #[derive(::serde::Serialize, ::serde::Deserialize)] #[serde(default)] -pub struct EchoRequest { +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 EchoRequest { +impl ::core::fmt::Debug for SayRequest { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - f.debug_struct("EchoRequest").field("message", &self.message).finish() + f.debug_struct("SayRequest").field("message", &self.message).finish() } } -impl EchoRequest { +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.EchoRequest"; + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayRequest"; } -impl EchoRequest { +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`. @@ -33,14 +33,14 @@ impl EchoRequest { self } } -::buffa::impl_default_instance!(EchoRequest); -impl ::buffa::MessageName for EchoRequest { +::buffa::impl_default_instance!(SayRequest); +impl ::buffa::MessageName for SayRequest { const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; - const NAME: &'static str = "EchoRequest"; - const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoRequest"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest"; + 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 EchoRequest { +impl ::buffa::Message for SayRequest { /// Returns the total encoded size in bytes. /// /// Accumulates in `u64` (which cannot overflow for in-memory @@ -100,7 +100,7 @@ impl ::buffa::Message for EchoRequest { self.message = ::core::option::Option::None; } } -impl ::buffa::json_helpers::ProtoElemJson for EchoRequest { +impl ::buffa::json_helpers::ProtoElemJson for SayRequest { fn serialize_proto_json( v: &Self, s: S, @@ -114,33 +114,33 @@ impl ::buffa::json_helpers::ProtoElemJson for EchoRequest { } } #[doc(hidden)] -pub const __ECHO_REQUEST_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { - type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoRequest", - to_json: ::buffa::type_registry::any_to_json::, - from_json: ::buffa::type_registry::any_from_json::, +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 EchoReply { +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 EchoReply { +impl ::core::fmt::Debug for SayResponse { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - f.debug_struct("EchoReply").field("message", &self.message).finish() + f.debug_struct("SayResponse").field("message", &self.message).finish() } } -impl EchoReply { +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.EchoReply"; + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.SayResponse"; } -impl EchoReply { +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`. @@ -152,14 +152,14 @@ impl EchoReply { self } } -::buffa::impl_default_instance!(EchoReply); -impl ::buffa::MessageName for EchoReply { +::buffa::impl_default_instance!(SayResponse); +impl ::buffa::MessageName for SayResponse { const PACKAGE: &'static str = "trogonai.grpc_nats_micro.v1"; - const NAME: &'static str = "EchoReply"; - const FULL_NAME: &'static str = "trogonai.grpc_nats_micro.v1.EchoReply"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply"; + 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 EchoReply { +impl ::buffa::Message for SayResponse { /// Returns the total encoded size in bytes. /// /// Accumulates in `u64` (which cannot overflow for in-memory @@ -219,7 +219,7 @@ impl ::buffa::Message for EchoReply { self.message = ::core::option::Option::None; } } -impl ::buffa::json_helpers::ProtoElemJson for EchoReply { +impl ::buffa::json_helpers::ProtoElemJson for SayResponse { fn serialize_proto_json( v: &Self, s: S, @@ -233,10 +233,10 @@ impl ::buffa::json_helpers::ProtoElemJson for EchoReply { } } #[doc(hidden)] -pub const __ECHO_REPLY_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { - type_url: "type.googleapis.com/trogonai.grpc_nats_micro.v1.EchoReply", - to_json: ::buffa::type_registry::any_to_json::, - from_json: ::buffa::type_registry::any_from_json::, +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)] @@ -398,3 +398,122 @@ pub const __FAIL_REQUEST_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buff 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 index 6abfe8b8e7..b3f900076d 100644 --- 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 @@ -22,22 +22,27 @@ pub mod __buffa { } /// 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::__ECHO_REQUEST_JSON_ANY); - reg.register_json_any(super::__ECHO_REPLY_JSON_ANY); + 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::EchoRequestView; +pub use self::__buffa::view::SayRequestView; #[doc(inline)] -pub use self::__buffa::view::EchoRequestOwnedView; +pub use self::__buffa::view::SayRequestOwnedView; #[doc(inline)] -pub use self::__buffa::view::EchoReplyView; +pub use self::__buffa::view::SayResponseView; #[doc(inline)] -pub use self::__buffa::view::EchoReplyOwnedView; +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; From 568b5c5185c3fe90826b265d9b45a0c27737aa54 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 01:02:39 -0400 Subject: [PATCH 03/14] fix(grpc-nats-micro): keep fault details and discovery names faithful to the binding Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 1 + .../platform/grpc-nats-micro/Cargo.toml | 1 + .../grpc-nats-micro/src/content_type.rs | 33 ++-- .../platform/grpc-nats-micro/src/server.rs | 16 +- .../grpc-nats-micro/src/status_codec.rs | 52 ++++-- .../grpc-nats-micro/tests/echo_conformance.rs | 155 ++++++++++++++++-- 6 files changed, 216 insertions(+), 42 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 1c6a5ff921..8000d7333a 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -2795,6 +2795,7 @@ version = "0.1.0" dependencies = [ "async-nats", "buffa", + "buffa-types", "bytes", "futures-util", "serde", diff --git a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml index 523756d2f5..fada38dd3c 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -22,4 +22,5 @@ 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", "process", "time"] } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs index cb4f5cb816..6a1cc74776 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -22,6 +22,16 @@ impl ContentType { } } + /// The encoding a `Content-Type` header value names, or `None` if the + /// value is not one this binding speaks (ADR 0016 §4). + pub fn from_header_value(value: &str) -> Option { + match value { + 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. @@ -31,19 +41,16 @@ impl ContentType { pub fn negotiate(policy: &ServiceOptions, header: Option<&str>) -> Result { let allowed = Self::allowed(policy); match header { - Some(CONTENT_TYPE_PROTOBUF) => match allowed { - Allowed::Either | Allowed::Only(Self::Protobuf) => Ok(Self::Protobuf), - Allowed::Only(Self::Json) => Err(NegotiationError::NotAllowed { - requested: Self::Protobuf, - }), - }, - Some(CONTENT_TYPE_JSON) => match allowed { - Allowed::Either | Allowed::Only(Self::Json) => Ok(Self::Json), - Allowed::Only(Self::Protobuf) => Err(NegotiationError::NotAllowed { requested: Self::Json }), - }, - Some(other) => Err(NegotiationError::UnknownContentType { - value: other.to_string(), - }), + Some(value) => { + let requested = Self::from_header_value(value).ok_or_else(|| NegotiationError::UnknownContentType { + value: value.to_string(), + })?; + 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), diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index 0e43bd72e5..5a68bf5f98 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -78,8 +78,14 @@ pub async fn serve( 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 micro_endpoint = service - .endpoint(subject.clone()) + .endpoint_builder() + .name(endpoint.method_name()) + .add(subject.clone()) .await .map_err(|source| ServeError::Endpoint { subject: subject.clone(), @@ -129,7 +135,13 @@ async fn dispatch( message: error.to_string(), details: Vec::new(), }; - reply_error(client, request, status, ContentType::Protobuf).await; + // Report the rejection in the encoding the caller asked for, so a + // caller the policy turns away can still read why. An encoding + // this binding does not speak leaves protobuf as the only choice. + let rejection_content_type = header_value + .and_then(ContentType::from_header_value) + .unwrap_or(ContentType::Protobuf); + reply_error(client, request, status, rejection_content_type).await; return; } }; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index 5786e807fa..0e994534ad 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -8,7 +8,7 @@ use bytes::Bytes; use thiserror::Error; use trogonai_proto::google::rpc::{Code, Status}; -use crate::constants::{HEADER_ERROR, HEADER_ERROR_CODE}; +use crate::constants::{HEADER_CONTENT_TYPE, HEADER_ERROR, HEADER_ERROR_CODE}; use crate::content_type::{ContentType, DecodeError, EncodeError}; /// A successful reply body, or a fault reported on the micro error channel. @@ -52,21 +52,37 @@ pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result Self { - Self { - code: header_code, - message: status.message, - } + fn from_status(header_code: i32, mut status: Status) -> Self { + status.code = header_code; + Self { status } + } + + pub fn code(&self) -> i32 { + self.status.code + } + + pub fn message(&self) -> &str { + &self.status.message + } + + pub fn status(&self) -> &Status { + &self.status + } + + pub fn into_status(self) -> Status { + self.status } } @@ -76,14 +92,18 @@ impl ServiceError { /// is the canonical [`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`. -pub fn decode_reply( - headers: Option<&HeaderMap>, - body: &[u8], - content_type: ContentType, -) -> Result +/// +/// `requested` is only a fallback: 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. +pub fn decode_reply(headers: Option<&HeaderMap>, body: &[u8], requested: ContentType) -> Result where Resp: buffa::Message + serde::de::DeserializeOwned, { + let content_type = headers + .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) + .and_then(|value| ContentType::from_header_value(value.as_str())) + .unwrap_or(requested); let error_code = headers.and_then(|headers| headers.get(HEADER_ERROR_CODE)); match error_code { diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index bf52a85247..17a411dec5 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -10,11 +10,14 @@ 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::constants::HEADER_ERROR_CODE; +use grpc_nats_micro::status_codec::ReplyError; use grpc_nats_micro::{ContentType, EndpointHandler, ServiceBinding}; use tokio::process::{Child, Command}; -use trogonai_proto::google::rpc::{Code, Status}; -use trogonai_proto::grpc_nats_micro::v1::{FailRequest, SayRequest, SayResponse}; +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"; @@ -22,6 +25,8 @@ const SERVICE_VERSION: &str = "0.1.0"; const SAY_METHOD: &str = "Say"; const FAIL_METHOD: &str = "Fail"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const FAIL_DETAIL_REASON: &str = "ECHO_FAIL"; +const FAIL_DETAIL_DOMAIN: &str = "grpc-nats-micro.conformance"; struct NatsServerProcess { child: Child, @@ -114,10 +119,15 @@ impl EndpointHandler for FailHandler { details: Vec::new(), })?; 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(Status { code: code.to_i32(), message: request.message.unwrap_or_default(), - details: Vec::new(), + details: vec![Any::pack(&detail, ErrorInfo::TYPE_URL)], }) }) } @@ -141,20 +151,19 @@ struct EchoFixture { } 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 = NatsServerProcess::spawn().await; let client = async_nats::connect(server.url()).await.expect("connect to nats-server"); let binding = echo_service_binding(); let handlers: Vec> = vec![Box::new(SayHandler), Box::new(FailHandler)]; - let service = grpc_nats_micro::serve( - &client, - &binding, - trogonai_proto::nats::micro::v1alpha1::ServiceOptions::default(), - handlers, - ) - .await - .expect("start EchoService"); + let service = grpc_nats_micro::serve(&client, &binding, content_type_policy, handlers) + .await + .expect("start EchoService"); EchoFixture { _server: server, @@ -255,6 +264,130 @@ async fn assert_fail_reports_status(content_type: ContentType) { .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::RESOURCE_EXHAUSTED.to_i32()); + 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() == 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; +} + +/// 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 response = tokio::time::timeout( + REQUEST_TIMEOUT, + fixture + .client + .request(format!("$SRV.INFO.{SERVICE_NAME}"), bytes::Bytes::new()), + ) + .await + .expect("$SRV.INFO did not time out") + .expect("$SRV.INFO responded"); + + let info: serde_json::Value = serde_json::from_slice(&response.payload).expect("decode $SRV.INFO record"); + 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]); +} + +/// 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::INVALID_ARGUMENT.to_i32()); } #[tokio::test] From ab0d9b4134edd07d3266811a0560bcf4c14acd55 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 01:16:25 -0400 Subject: [PATCH 04/14] feat(grpc-nats-micro): make binding identifiers unrepresentable when invalid A malformed subject prefix, service name, or method name only surfaced at NATS registration time, far from where it was introduced. Signed-off-by: Yordis Prieto --- .../platform/grpc-nats-micro/src/binding.rs | 67 ++++++++----------- .../grpc-nats-micro/src/endpoint_subject.rs | 57 ++++++++++++++++ .../src/endpoint_subject/tests.rs | 31 +++++++++ .../platform/grpc-nats-micro/src/lib.rs | 11 ++- .../grpc-nats-micro/src/method_name.rs | 67 +++++++++++++++++++ .../grpc-nats-micro/src/method_name/tests.rs | 26 +++++++ .../platform/grpc-nats-micro/src/server.rs | 17 ++--- .../grpc-nats-micro/src/service_name.rs | 67 +++++++++++++++++++ .../grpc-nats-micro/src/service_name/tests.rs | 34 ++++++++++ .../grpc-nats-micro/src/subject_prefix.rs | 52 ++++++++++++++ .../src/subject_prefix/tests.rs | 48 +++++++++++++ .../grpc-nats-micro/tests/echo_conformance.rs | 38 ++++++++--- 12 files changed, 456 insertions(+), 59 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs index cf352dcdf1..e91b453a8c 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -1,44 +1,30 @@ -//! Binding descriptors: subject derivation per ADR 0016 §2 -//! (`..`). +//! Binding descriptors: the annotated protobuf service and its `rpc` methods, +//! bound to NATS micro per ADR 0016 §1 and §2. -/// A NATS subject derived from a subject prefix, service name, and method -/// name, per ADR 0016 §2. Always constructed through [`EndpointSubject::new`] -/// so the derivation rule cannot drift out of sync at a call site. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EndpointSubject(String); - -impl EndpointSubject { - pub fn new(subject_prefix: &str, service_name: &str, method_name: &str) -> Self { - Self(format!("{subject_prefix}.{service_name}.{method_name}")) - } - - 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.0) - } -} +use crate::endpoint_subject::{EndpointSubject, EndpointSubjectError}; +use crate::method_name::MethodName; +use crate::service_name::ServiceName; +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: String, + method_name: MethodName, subject: EndpointSubject, } impl EndpointBinding { - pub fn new(subject_prefix: &str, service_name: &str, method_name: impl Into) -> Self { - let method_name = method_name.into(); - let subject = EndpointSubject::new(subject_prefix, service_name, &method_name); - Self { method_name, subject } + pub fn new( + subject_prefix: &SubjectPrefix, + service_name: &ServiceName, + method_name: MethodName, + ) -> Result { + let subject = EndpointSubject::new(subject_prefix, service_name, &method_name)?; + Ok(Self { method_name, subject }) } - pub fn method_name(&self) -> &str { + pub fn method_name(&self) -> &MethodName { &self.method_name } @@ -51,20 +37,20 @@ impl EndpointBinding { /// (ADR 0016 §1), and the subject prefix its endpoints are derived under. #[derive(Debug, Clone)] pub struct ServiceBinding { - name: String, + name: ServiceName, version: String, description: Option, - subject_prefix: String, + subject_prefix: SubjectPrefix, endpoints: Vec, } impl ServiceBinding { - pub fn new(name: impl Into, version: impl Into, subject_prefix: impl Into) -> Self { + pub fn new(name: ServiceName, version: impl Into, subject_prefix: SubjectPrefix) -> Self { Self { - name: name.into(), + name, version: version.into(), description: None, - subject_prefix: subject_prefix.into(), + subject_prefix, endpoints: Vec::new(), } } @@ -77,14 +63,13 @@ impl ServiceBinding { /// Register an `rpc` method as a micro endpoint, deriving its subject /// from this binding's subject prefix and service name. - #[must_use = "with_* setters return `self` by value; assign or chain the result"] - pub fn with_method(mut self, method_name: impl Into) -> Self { + pub fn with_method(mut self, method_name: MethodName) -> Result { self.endpoints - .push(EndpointBinding::new(&self.subject_prefix, &self.name, method_name)); - self + .push(EndpointBinding::new(&self.subject_prefix, &self.name, method_name)?); + Ok(self) } - pub fn name(&self) -> &str { + pub fn name(&self) -> &ServiceName { &self.name } @@ -96,6 +81,10 @@ impl ServiceBinding { self.description.as_deref() } + pub fn subject_prefix(&self) -> &SubjectPrefix { + &self.subject_prefix + } + pub fn endpoints(&self) -> &[EndpointBinding] { &self.endpoints } 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..4a35e07f04 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs @@ -0,0 +1,57 @@ +//! 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. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[error("derived endpoint subject {subject:?} is not a conformant published subject")] +pub struct EndpointSubjectError { + pub subject: String, + #[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: subject.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..82cc519d49 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs @@ -0,0 +1,31 @@ +use super::EndpointSubject; +use crate::method_name::MethodName; +use crate::service_name::ServiceName; +use crate::subject_prefix::SubjectPrefix; + +fn subject(prefix: &str) -> Result { + EndpointSubject::new( + &SubjectPrefix::new(prefix).expect("valid prefix"), + &ServiceName::new("EchoService").expect("valid service name"), + &MethodName::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 { .. } + )); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs index 3e9170bf03..b5622e4ec0 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -1,3 +1,4 @@ +#![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 @@ -13,10 +14,18 @@ pub mod binding; pub mod client; pub mod constants; pub mod content_type; +pub mod endpoint_subject; +pub mod method_name; pub mod server; +pub mod service_name; pub mod status_codec; +pub mod subject_prefix; -pub use binding::{EndpointBinding, EndpointSubject, ServiceBinding}; +pub use binding::{EndpointBinding, ServiceBinding}; pub use content_type::ContentType; +pub use endpoint_subject::{EndpointSubject, EndpointSubjectError}; +pub use method_name::{MethodName, MethodNameError}; pub use server::{EndpointHandler, ServeError, serve}; +pub use service_name::{ServiceName, ServiceNameError}; pub use status_codec::{EncodedReply, Outcome, ReplyError, ServiceError}; +pub use subject_prefix::{SubjectPrefix, SubjectPrefixError}; 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..8d9639002d --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs @@ -0,0 +1,67 @@ +//! 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}; + +/// 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 new(value: impl AsRef) -> Result { + let value = value.as_ref(); + 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..ddb8931357 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs @@ -0,0 +1,26 @@ +use super::{MethodName, MethodNameError}; + +#[test] +fn accepts_a_protobuf_method_name() { + let method = MethodName::new("Say").expect("protobuf method name is valid"); + assert_eq!(method.as_str(), "Say"); +} + +#[test] +fn rejects_empty() { + assert_eq!(MethodName::new(""), Err(MethodNameError::Empty)); +} + +#[test] +fn rejects_a_leading_digit() { + assert_eq!(MethodName::new("2Say"), Err(MethodNameError::LeadingCharacter('2'))); +} + +#[test] +fn rejects_subject_separators_and_wildcards() { + assert_eq!( + MethodName::new("Say.Again"), + Err(MethodNameError::InvalidCharacter('.')) + ); + assert_eq!(MethodName::new("Say>"), Err(MethodNameError::InvalidCharacter('>'))); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index 5a68bf5f98..1921eff3cc 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -13,6 +13,7 @@ use async_nats::{Client, HeaderMap}; use buffa::Enumeration as _; use futures_util::StreamExt as _; use thiserror::Error; +use trogon_nats::PublishClient; use trogonai_proto::google::rpc::{Code, Status}; use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; @@ -71,7 +72,7 @@ pub async fn serve( builder = builder.description(description); } let service = builder - .start(binding.name(), binding.version()) + .start(binding.name().as_str(), binding.version()) .await .map_err(ServeError::Start)?; @@ -84,7 +85,7 @@ pub async fn serve( // method the binding declared. let micro_endpoint = service .endpoint_builder() - .name(endpoint.method_name()) + .name(endpoint.method_name().as_str()) .add(subject.clone()) .await .map_err(|source| ServeError::Endpoint { @@ -103,8 +104,8 @@ pub async fn serve( Ok(service) } -async fn run_endpoint( - client: Client, +async fn run_endpoint( + client: P, mut micro_endpoint: async_nats::service::endpoint::Endpoint, content_type_policy: Arc, handler: Box, @@ -114,8 +115,8 @@ async fn run_endpoint( } } -async fn dispatch( - client: &Client, +async fn dispatch( + client: &P, request: &async_nats::service::Request, content_type_policy: &ServiceOptions, handler: &dyn EndpointHandler, @@ -174,8 +175,8 @@ async fn reply_success(request: &async_nats::service::Request, body: Vec, co /// `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: &Client, +async fn reply_error( + client: &P, request: &async_nats::service::Request, status: Status, content_type: ContentType, 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..6f6d8d27c8 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs @@ -0,0 +1,67 @@ +//! 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}; + +/// 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 new(value: impl AsRef) -> Result { + let value = value.as_ref(); + 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..ded6f45e10 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs @@ -0,0 +1,34 @@ +use super::{ServiceName, ServiceNameError}; + +#[test] +fn accepts_a_protobuf_service_name() { + let name = ServiceName::new("EchoService").expect("protobuf service name is valid"); + assert_eq!(name.as_str(), "EchoService"); +} + +#[test] +fn rejects_empty() { + assert_eq!(ServiceName::new(""), Err(ServiceNameError::Empty)); +} + +#[test] +fn rejects_a_leading_digit() { + assert_eq!(ServiceName::new("1Echo"), Err(ServiceNameError::LeadingCharacter('1'))); +} + +#[test] +fn rejects_subject_separators_and_wildcards() { + assert_eq!( + ServiceName::new("echo.v1"), + Err(ServiceNameError::InvalidCharacter('.')) + ); + assert_eq!(ServiceName::new("Echo*"), Err(ServiceNameError::InvalidCharacter('*'))); +} + +#[test] +fn rejects_characters_outside_the_protobuf_identifier_grammar() { + assert_eq!( + ServiceName::new("Echo-Service"), + Err(ServiceNameError::InvalidCharacter('-')) + ); +} 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..deee906496 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs @@ -0,0 +1,52 @@ +//! The NATS subject namespace one service's endpoints are derived under +//! (ADR 0016 §2). + +use trogon_nats::{DottedNatsToken, SubjectTokenViolationError}; + +/// 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 new(value: impl AsRef) -> Result { + DottedNatsToken::new(value).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..b92b76ea62 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs @@ -0,0 +1,48 @@ +use super::{SubjectPrefix, SubjectPrefixError}; + +#[test] +fn accepts_a_dotted_namespace() { + let prefix = SubjectPrefix::new("echo.v1").expect("dotted prefix is valid"); + assert_eq!(prefix.as_str(), "echo.v1"); +} + +#[test] +fn rejects_empty() { + assert_eq!(SubjectPrefix::new(""), Err(SubjectPrefixError::Empty)); +} + +#[test] +fn rejects_wildcards() { + assert_eq!( + SubjectPrefix::new("echo.*"), + Err(SubjectPrefixError::InvalidCharacter('*')) + ); + assert_eq!( + SubjectPrefix::new("echo.>"), + Err(SubjectPrefixError::InvalidCharacter('>')) + ); +} + +#[test] +fn rejects_malformed_dots() { + assert_eq!( + SubjectPrefix::new(".echo"), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); + assert_eq!( + SubjectPrefix::new("echo."), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); + assert_eq!( + SubjectPrefix::new("echo..v1"), + Err(SubjectPrefixError::InvalidCharacter('.')) + ); +} + +#[test] +fn rejects_whitespace() { + assert_eq!( + SubjectPrefix::new("echo v1"), + Err(SubjectPrefixError::InvalidCharacter(' ')) + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index 17a411dec5..5446b3ed91 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -13,8 +13,9 @@ use buffa::Enumeration as _; use buffa_types::google::protobuf::Any; use grpc_nats_micro::constants::HEADER_ERROR_CODE; use grpc_nats_micro::status_codec::ReplyError; -use grpc_nats_micro::{ContentType, EndpointHandler, ServiceBinding}; +use grpc_nats_micro::{ContentType, EndpointHandler, MethodName, ServiceBinding, ServiceName, SubjectPrefix}; use tokio::process::{Child, Command}; +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}; @@ -25,6 +26,7 @@ const SERVICE_VERSION: &str = "0.1.0"; const SAY_METHOD: &str = "Say"; const FAIL_METHOD: &str = "Fail"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const FAIL_DETAIL_REASON: &str = "ECHO_FAIL"; const FAIL_DETAIL_DOMAIN: &str = "grpc-nats-micro.conformance"; @@ -49,7 +51,7 @@ impl NatsServerProcess { async fn wait_until_ready(&self) { let deadline = tokio::time::Instant::now() + Duration::from_secs(10); loop { - if async_nats::connect(self.url()).await.is_ok() { + if connect(&self.url()).await.is_ok() { return; } if tokio::time::Instant::now() >= deadline { @@ -70,6 +72,10 @@ impl Drop for NatsServerProcess { } } +async fn connect(url: &str) -> Result { + trogon_nats::connect(&NatsConfig::from_url(url), CONNECT_TIMEOUT).await +} + fn free_port() -> u16 { TcpListener::bind("127.0.0.1:0") .expect("bind ephemeral port") @@ -134,9 +140,19 @@ impl EndpointHandler for FailHandler { } fn echo_service_binding() -> ServiceBinding { - ServiceBinding::new(SERVICE_NAME, SERVICE_VERSION, SUBJECT_PREFIX) - .with_method(SAY_METHOD) - .with_method(FAIL_METHOD) + ServiceBinding::new( + ServiceName::new(SERVICE_NAME).expect("valid service name"), + SERVICE_VERSION, + SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), + ) + .with_method(method(SAY_METHOD)) + .expect("derive Say subject") + .with_method(method(FAIL_METHOD)) + .expect("derive Fail subject") +} + +fn method(name: &str) -> MethodName { + MethodName::new(name).expect("valid method name") } /// Keeps the spawned `nats-server` process, the client, the service @@ -156,7 +172,7 @@ async fn start_fixture() -> EchoFixture { async fn start_fixture_with_policy(content_type_policy: ServiceOptions) -> EchoFixture { let server = NatsServerProcess::spawn().await; - let client = async_nats::connect(server.url()).await.expect("connect to nats-server"); + let client = connect(&server.url()).await.expect("connect to nats-server"); let binding = echo_service_binding(); let handlers: Vec> = vec![Box::new(SayHandler), Box::new(FailHandler)]; @@ -178,7 +194,7 @@ async fn say(fixture: &EchoFixture, content_type: ContentType, message: &str) -> .binding .endpoints() .iter() - .find(|endpoint| endpoint.method_name() == SAY_METHOD) + .find(|endpoint| endpoint.method_name().as_str() == SAY_METHOD) .expect("Say endpoint registered"); let request = SayRequest { @@ -194,7 +210,7 @@ async fn say(fixture: &EchoFixture, content_type: ContentType, message: &str) -> let subject = endpoint.subject().as_str().to_string(); tokio::time::timeout( REQUEST_TIMEOUT, - fixture.client.request_with_headers(subject, headers, body.into()), + RequestClient::request_with_headers(&fixture.client, subject, headers, body.into()), ) .await .expect("Say request did not time out") @@ -206,7 +222,7 @@ async fn fail(fixture: &EchoFixture, content_type: ContentType, code: Code, mess .binding .endpoints() .iter() - .find(|endpoint| endpoint.method_name() == FAIL_METHOD) + .find(|endpoint| endpoint.method_name().as_str() == FAIL_METHOD) .expect("Fail endpoint registered"); let request = FailRequest { @@ -223,7 +239,7 @@ async fn fail(fixture: &EchoFixture, content_type: ContentType, code: Code, mess let subject = endpoint.subject().as_str().to_string(); tokio::time::timeout( REQUEST_TIMEOUT, - fixture.client.request_with_headers(subject, headers, body.into()), + RequestClient::request_with_headers(&fixture.client, subject, headers, body.into()), ) .await .expect("Fail request did not time out") @@ -320,7 +336,7 @@ fn endpoint<'a>(fixture: &'a EchoFixture, method_name: &str) -> &'a grpc_nats_mi .binding .endpoints() .iter() - .find(|endpoint| endpoint.method_name() == method_name) + .find(|endpoint| endpoint.method_name().as_str() == method_name) .expect("endpoint registered") } From 5e70b97b68b0d2d2386d8c94ccfda8c8710ca687 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 01:26:34 -0400 Subject: [PATCH 05/14] feat(grpc-nats-micro): make an invalid error reply unrepresentable An OK-coded fault and an unknown error code header were repaired or accepted downstream instead of being rejected where they enter, and a short handler list registered a service whose declared methods never answered. Signed-off-by: Yordis Prieto --- .../grpc-nats-micro/src/content_type.rs | 22 +++--- .../grpc-nats-micro/src/content_type_input.rs | 23 +++++++ .../platform/grpc-nats-micro/src/lib.rs | 10 ++- .../platform/grpc-nats-micro/src/server.rs | 48 ++++++++----- .../grpc-nats-micro/src/service_error_code.rs | 64 ++++++++++++++++++ .../src/service_error_code/tests.rs | 42 ++++++++++++ .../src/service_error_code_input.rs | 25 +++++++ .../grpc-nats-micro/src/service_fault.rs | 67 +++++++++++++++++++ .../src/service_fault/tests.rs | 38 +++++++++++ .../grpc-nats-micro/src/status_codec.rs | 67 ++++++++----------- .../grpc-nats-micro/tests/echo_conformance.rs | 65 ++++++++++++------ 11 files changed, 381 insertions(+), 90 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/content_type_input.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code_input.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_fault.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs index 6a1cc74776..e7c38bddc1 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -3,6 +3,7 @@ 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)] @@ -22,10 +23,11 @@ impl ContentType { } } - /// The encoding a `Content-Type` header value names, or `None` if the - /// value is not one this binding speaks (ADR 0016 §4). - pub fn from_header_value(value: &str) -> Option { - match value { + /// 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, @@ -38,12 +40,12 @@ impl ContentType { /// /// 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<&str>) -> Result { + pub fn negotiate(policy: &ServiceOptions, header: Option<&ContentTypeInput>) -> Result { let allowed = Self::allowed(policy); match header { - Some(value) => { - let requested = Self::from_header_value(value).ok_or_else(|| NegotiationError::UnknownContentType { - value: value.to_string(), + Some(input) => { + let requested = Self::from_input(input).ok_or_else(|| NegotiationError::Unsupported { + requested: input.clone(), })?; match allowed { Allowed::Either => Ok(requested), @@ -98,8 +100,8 @@ enum Allowed { 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: {value}")] - UnknownContentType { value: String }, + #[error("unrecognized Content-Type header value: {requested}")] + Unsupported { requested: ContentTypeInput }, } #[derive(Debug, Error)] 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/lib.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs index b5622e4ec0..e12b1870e0 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -3,7 +3,7 @@ //! //! 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), +//! 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`]). //! @@ -14,18 +14,26 @@ pub mod binding; pub mod client; pub mod constants; pub mod content_type; +pub mod content_type_input; pub mod endpoint_subject; pub mod method_name; pub mod server; +pub mod service_error_code; +pub mod service_error_code_input; +pub mod service_fault; pub mod service_name; pub mod status_codec; pub mod subject_prefix; pub use binding::{EndpointBinding, ServiceBinding}; pub use content_type::ContentType; +pub use content_type_input::ContentTypeInput; pub use endpoint_subject::{EndpointSubject, EndpointSubjectError}; pub use method_name::{MethodName, MethodNameError}; 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 status_codec::{EncodedReply, Outcome, ReplyError, ServiceError}; pub use subject_prefix::{SubjectPrefix, SubjectPrefixError}; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index 1921eff3cc..0f8f620e7d 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -10,23 +10,24 @@ use std::sync::Arc; use async_nats::service::ServiceExt as _; use async_nats::{Client, HeaderMap}; -use buffa::Enumeration as _; use futures_util::StreamExt as _; use thiserror::Error; use trogon_nats::PublishClient; -use trogonai_proto::google::rpc::{Code, Status}; use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; use crate::binding::ServiceBinding; use crate::constants::HEADER_CONTENT_TYPE; use crate::content_type::ContentType; +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 [`Status`] to report on the micro error channel. +/// `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 @@ -38,11 +39,13 @@ pub trait EndpointHandler: Send + Sync { &'a self, request_bytes: &'a [u8], content_type: ContentType, - ) -> Pin, Status>> + Send + 'a>>; + ) -> 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}")] @@ -67,6 +70,13 @@ pub async fn serve( 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); @@ -121,35 +131,37 @@ async fn dispatch( content_type_policy: &ServiceOptions, handler: &dyn EndpointHandler, ) { - let header_value = request + let requested = request .message .headers .as_ref() .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) - .map(|value| value.as_str()); + .map(|value| ContentTypeInput::new(value.as_str())); - let content_type = match ContentType::negotiate(content_type_policy, header_value) { + let content_type = match ContentType::negotiate(content_type_policy, requested.as_ref()) { Ok(content_type) => content_type, Err(error) => { - let status = Status { - code: Code::INVALID_ARGUMENT.to_i32(), - message: error.to_string(), - details: Vec::new(), - }; // Report the rejection in the encoding the caller asked for, so a // caller the policy turns away can still read why. An encoding // this binding does not speak leaves protobuf as the only choice. - let rejection_content_type = header_value - .and_then(ContentType::from_header_value) + let rejection_content_type = requested + .as_ref() + .and_then(ContentType::from_input) .unwrap_or(ContentType::Protobuf); - reply_error(client, request, status, rejection_content_type).await; + reply_error( + client, + request, + ServiceFault::invalid_argument(error.to_string()), + rejection_content_type, + ) + .await; return; } }; match handler.handle(&request.message.payload, content_type).await { Ok(body) => reply_success(request, body, content_type).await, - Err(status) => reply_error(client, request, status, content_type).await, + Err(fault) => reply_error(client, request, fault, content_type).await, } } @@ -178,14 +190,14 @@ async fn reply_success(request: &async_nats::service::Request, body: Vec, co async fn reply_error( client: &P, request: &async_nats::service::Request, - status: Status, + fault: ServiceFault, content_type: ContentType, ) { let Some(reply) = request.message.reply.clone() else { tracing::warn!("grpc-nats-micro: request had no reply subject; dropping error reply"); return; }; - let encoded = match status_codec::encode_reply(Outcome::Error(status), content_type) { + let encoded = match status_codec::encode_reply(Outcome::Error(fault), content_type) { Ok(encoded) => encoded, Err(error) => { tracing::warn!(error = %error, "grpc-nats-micro: failed to encode error reply"); 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..ff9ad02e20 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs @@ -0,0 +1,42 @@ +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 }) + ); +} + +#[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..b8e2d131e6 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs @@ -0,0 +1,38 @@ +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); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index 0e994534ad..a5c00443d3 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -3,18 +3,21 @@ //! complete `google.rpc.Status` encoded per the negotiated [`ContentType`]. use async_nats::HeaderMap; -use buffa::Enumeration as _; use bytes::Bytes; use thiserror::Error; -use trogonai_proto::google::rpc::{Code, Status}; +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(Status), + Error(ServiceFault), } /// Headers and body ready to publish as a NATS reply. @@ -25,25 +28,17 @@ pub struct EncodedReply { /// Server-side: encode an [`Outcome`] into the headers and body a NATS reply /// needs, per ADR 0016 §3. -/// -/// An `Outcome::Error` whose `code` is `OK` (0) is a programmer error: this -/// coerces it to `INTERNAL` rather than emit an error reply with no error -/// code, since the ADR requires the error code space to never contain `OK` -/// on an error reply. pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result { match outcome { Outcome::Success(body) => Ok(EncodedReply { headers: HeaderMap::new(), body, }), - Outcome::Error(mut status) => { - if status.code == Code::OK.to_i32() { - status.code = Code::INTERNAL.to_i32(); - } - let body = content_type.encode(&status)?; + Outcome::Error(fault) => { + let body = content_type.encode(fault.status())?; let mut headers = HeaderMap::new(); - headers.insert(HEADER_ERROR, status.message.as_str()); - headers.insert(HEADER_ERROR_CODE, status.code.to_string().as_str()); + headers.insert(HEADER_ERROR, fault.message()); + headers.insert(HEADER_ERROR_CODE, fault.code().to_i32().to_string().as_str()); Ok(EncodedReply { headers, body: Bytes::from(body), @@ -55,41 +50,35 @@ pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result Self { - status.code = header_code; - Self { status } - } - - pub fn code(&self) -> i32 { - self.status.code + pub const fn code(&self) -> ServiceErrorCode { + self.fault.code() } pub fn message(&self) -> &str { - &self.status.message + self.fault.message() } pub fn status(&self) -> &Status { - &self.status + self.fault.status() } pub fn into_status(self) -> 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 [`Code`], authoritative over the body's `code` field on +/// 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`. /// @@ -102,20 +91,18 @@ where { let content_type = headers .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) - .and_then(|value| ContentType::from_header_value(value.as_str())) + .and_then(|value| ContentType::from_input(&ContentTypeInput::new(value.as_str()))) .unwrap_or(requested); let error_code = headers.and_then(|headers| headers.get(HEADER_ERROR_CODE)); match error_code { Some(code_header) => { - let header_code: i32 = code_header - .as_str() - .parse() - .map_err(|_| ReplyError::InvalidErrorCodeHeader { - value: code_header.as_str().to_string(), - })?; + 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::from_status(header_code, status))) + Err(ReplyError::Service(ServiceError { + fault: ServiceFault::with_code(code, status), + })) } None => content_type.decode(body).map_err(ReplyError::Decode), } @@ -123,8 +110,8 @@ where #[derive(Debug, Error)] pub enum ReplyError { - #[error("invalid {HEADER_ERROR_CODE} header value: {value}")] - InvalidErrorCodeHeader { value: String }, + #[error("invalid {HEADER_ERROR_CODE} header")] + ErrorCode(#[source] ServiceErrorCodeError), #[error("failed to decode reply payload")] Decode(#[source] DecodeError), #[error(transparent)] diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index 5446b3ed91..14bc8dedb2 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -13,7 +13,9 @@ use buffa::Enumeration as _; use buffa_types::google::protobuf::Any; use grpc_nats_micro::constants::HEADER_ERROR_CODE; use grpc_nats_micro::status_codec::ReplyError; -use grpc_nats_micro::{ContentType, EndpointHandler, MethodName, ServiceBinding, ServiceName, SubjectPrefix}; +use grpc_nats_micro::{ + ContentType, EndpointHandler, MethodName, ServiceBinding, ServiceFault, ServiceName, SubjectPrefix, +}; use tokio::process::{Child, Command}; use trogon_nats::{NatsConfig, RequestClient}; use trogonai_proto::google::rpc::{Code, ErrorInfo, Status}; @@ -91,21 +93,17 @@ impl EndpointHandler for SayHandler { &'a self, request_bytes: &'a [u8], content_type: ContentType, - ) -> Pin, Status>> + Send + 'a>> { + ) -> Pin, ServiceFault>> + Send + 'a>> { Box::pin(async move { - let request: SayRequest = content_type.decode(request_bytes).map_err(|error| Status { - code: Code::INVALID_ARGUMENT.to_i32(), - message: error.to_string(), - details: Vec::new(), - })?; + 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| Status { - code: Code::INTERNAL.to_i32(), - message: error.to_string(), - details: Vec::new(), - }) + content_type + .encode(&reply) + .map_err(|error| ServiceFault::internal(error.to_string())) }) } } @@ -117,24 +115,23 @@ impl EndpointHandler for FailHandler { &'a self, request_bytes: &'a [u8], content_type: ContentType, - ) -> Pin, Status>> + Send + 'a>> { + ) -> Pin, ServiceFault>> + Send + 'a>> { Box::pin(async move { - let request: FailRequest = content_type.decode(request_bytes).map_err(|error| Status { - code: Code::INVALID_ARGUMENT.to_i32(), - message: error.to_string(), - details: Vec::new(), - })?; + 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(Status { + 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")) }) } } @@ -305,7 +302,7 @@ async fn assert_fail_details_reach_the_client(content_type: ContentType) { .expect_err("Fail must surface a service error"); let service_error = service_error(error); - assert_eq!(service_error.code(), Code::RESOURCE_EXHAUSTED.to_i32()); + 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); @@ -403,7 +400,7 @@ async fn rejected_content_type_reports_the_policy_status() { .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::INVALID_ARGUMENT.to_i32()); + assert_eq!(service_error.code().code(), Code::INVALID_ARGUMENT); } #[tokio::test] @@ -425,3 +422,29 @@ async fn fail_reports_status_over_protobuf() { 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 = NatsServerProcess::spawn().await; + let client = connect(&server.url()).await.expect("connect to nats-server"); + 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 + } + )); +} From 4b7f1fd341ef58820c6a483d522db463144402c0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 01:51:56 -0400 Subject: [PATCH 06/14] chore(mise): pin nats-server for the workspace toolchain The ADR 0016 conformance tests exercise a real micro service, so CI needs the same server every contributor already runs locally rather than one it happens to find on PATH. Signed-off-by: Yordis Prieto --- .mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.mise.toml b/.mise.toml index 37436d91b1..4a6f9f6234 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,7 @@ [tools] buf = "1.70.0" go = "1.26.5" +"ubi:nats-io/nats-server" = { version = "2.14.5", exe = "nats-server" } "ubi:open-telemetry/weaver" = { version = "0.24.2", exe = "weaver" } "cargo:cargo-dylint" = "6.0.1" "cargo:dylint-link" = "6.0.1" From 87eccb1754c27e3becd9fa0b57d50d9bd80b1628 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 03:10:42 -0400 Subject: [PATCH 07/14] fix(grpc-nats-micro): keep every dispatch path exercised by tests The reply paths that only ran under failure had no test reaching them, so the coverage gate blocked the branch and the stop test raced the unsubscribe it was asserting on. Signed-off-by: Yordis Prieto --- .../platform/grpc-nats-micro/Cargo.toml | 1 + .../platform/grpc-nats-micro/src/binding.rs | 3 + .../grpc-nats-micro/src/binding/tests.rs | 35 ++++ .../platform/grpc-nats-micro/src/client.rs | 3 + .../grpc-nats-micro/src/client/tests.rs | 135 ++++++++++++++ .../grpc-nats-micro/src/content_type.rs | 3 + .../grpc-nats-micro/src/content_type/tests.rs | 85 +++++++++ .../src/endpoint_subject/tests.rs | 6 + .../grpc-nats-micro/src/method_name/tests.rs | 14 ++ .../platform/grpc-nats-micro/src/server.rs | 160 ++++++++++------- .../grpc-nats-micro/src/server/tests.rs | 168 ++++++++++++++++++ .../src/service_error_code/tests.rs | 6 +- .../src/service_fault/tests.rs | 10 ++ .../grpc-nats-micro/src/service_name/tests.rs | 6 + .../grpc-nats-micro/src/status_codec.rs | 3 + .../grpc-nats-micro/src/status_codec/tests.rs | 131 ++++++++++++++ .../src/subject_prefix/tests.rs | 6 + .../grpc-nats-micro/tests/echo_conformance.rs | 46 +++++ 18 files changed, 757 insertions(+), 64 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/content_type/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/server/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs diff --git a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml index fada38dd3c..862db29b2b 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -24,3 +24,4 @@ trogonai-proto = { workspace = true, features = ["grpc-nats-micro"] } [dev-dependencies] buffa-types = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "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 index e91b453a8c..c34060fd88 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -89,3 +89,6 @@ impl ServiceBinding { &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..396361ab1a --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -0,0 +1,35 @@ +use super::ServiceBinding; +use crate::method_name::MethodName; +use crate::service_name::ServiceName; +use crate::subject_prefix::SubjectPrefix; + +const SUBJECT_PREFIX: &str = "echo.v1"; + +fn binding() -> ServiceBinding { + ServiceBinding::new( + ServiceName::new("EchoService").expect("valid service name"), + "0.1.0", + SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), + ) + .with_method(MethodName::new("Say").expect("valid method name")) + .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") + ); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs index edd4846dcd..6ae270766f 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client.rs @@ -74,3 +74,6 @@ where 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..407eaf2754 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs @@ -0,0 +1,135 @@ +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::method_name::MethodName; +use crate::service_name::ServiceName; +use crate::subject_prefix::SubjectPrefix; + +const SAY_SUBJECT: &str = "echo.v1.EchoService.Say"; +const REQUEST_TIMEOUT: Duration = Duration::from_millis(50); + +fn binding() -> ServiceBinding { + ServiceBinding::new( + ServiceName::new("EchoService").expect("valid service name"), + "0.1.0", + SubjectPrefix::new("echo.v1").expect("valid subject prefix"), + ) + .with_method(MethodName::new("Say").expect("valid method name")) + .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/content_type.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs index e7c38bddc1..3836f4ee11 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/content_type.rs @@ -117,3 +117,6 @@ pub enum 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/endpoint_subject/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs index 82cc519d49..bc0e3c159f 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs @@ -29,3 +29,9 @@ fn rejects_a_subject_over_the_token_budget() { 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"); +} 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 index ddb8931357..87882cff8a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs @@ -24,3 +24,17 @@ fn rejects_subject_separators_and_wildcards() { ); assert_eq!(MethodName::new("Say>"), Err(MethodNameError::InvalidCharacter('>'))); } + +#[test] +fn rejects_characters_outside_the_protobuf_identifier_grammar() { + assert_eq!( + MethodName::new("Say-Again"), + Err(MethodNameError::InvalidCharacter('-')) + ); +} + +#[test] +fn rejects_a_name_over_the_subject_token_budget() { + let long = "S".repeat(129); + assert_eq!(MethodName::new(&long), Err(MethodNameError::TooLong(129))); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index 0f8f620e7d..b8c79bab9c 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -17,7 +17,7 @@ use trogonai_proto::nats::micro::v1alpha1::ServiceOptions; use crate::binding::ServiceBinding; use crate::constants::HEADER_CONTENT_TYPE; -use crate::content_type::ContentType; +use crate::content_type::{ContentType, EncodeError}; use crate::content_type_input::ContentTypeInput; use crate::service_fault::ServiceFault; use crate::status_codec::{self, Outcome}; @@ -93,87 +93,90 @@ pub async fn serve( // 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 micro_endpoint = service + let registration = service .endpoint_builder() .name(endpoint.method_name().as_str()) .add(subject.clone()) - .await - .map_err(|source| ServeError::Endpoint { - subject: subject.clone(), - source, - })?; - - tokio::spawn(run_endpoint( - client.clone(), - micro_endpoint, - content_type_policy.clone(), - handler, - )); + .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 run_endpoint( - client: P, - mut micro_endpoint: async_nats::service::endpoint::Endpoint, - content_type_policy: Arc, - handler: Box, -) { - while let Some(request) = micro_endpoint.next().await { - dispatch(&client, &request, &content_type_policy, handler.as_ref()).await; - } -} - async fn dispatch( client: &P, request: &async_nats::service::Request, content_type_policy: &ServiceOptions, handler: &dyn EndpointHandler, ) { - let requested = request - .message - .headers - .as_ref() + 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())); - let content_type = match ContentType::negotiate(content_type_policy, requested.as_ref()) { - Ok(content_type) => content_type, + 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) => { - // Report the rejection in the encoding the caller asked for, so a - // caller the policy turns away can still read why. An encoding - // this binding does not speak leaves protobuf as the only choice. let rejection_content_type = requested .as_ref() .and_then(ContentType::from_input) .unwrap_or(ContentType::Protobuf); - reply_error( - client, - request, - ServiceFault::invalid_argument(error.to_string()), + ( rejection_content_type, + Err(ServiceFault::invalid_argument(error.to_string())), ) - .await; - return; } - }; - - match handler.handle(&request.message.payload, content_type).await { - Ok(body) => reply_success(request, body, content_type).await, - Err(fault) => reply_error(client, request, fault, content_type).await, } } 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()); - if let Err(source) = request + let published = request .respond_with_headers(Ok(bytes::Bytes::from(body)), headers) - .await - { - tracing::warn!(error = %source, "grpc-nats-micro: failed to publish success reply"); - } + .await; + warn_if_undelivered(published, ReplyKind::Success); } /// Publish an error reply directly on the client, bypassing @@ -189,24 +192,55 @@ async fn reply_success(request: &async_nats::service::Request, body: Vec, co /// convenience micro provides, not an invariant, so body-completeness wins. async fn reply_error( client: &P, - request: &async_nats::service::Request, + reply: Option, fault: ServiceFault, content_type: ContentType, -) { - let Some(reply) = request.message.reply.clone() else { +) -> Result<(), EncodeError> { + let Some(reply) = reply else { tracing::warn!("grpc-nats-micro: request had no reply subject; dropping error reply"); - return; - }; - let encoded = match status_codec::encode_reply(Outcome::Error(fault), content_type) { - Ok(encoded) => encoded, - Err(error) => { - tracing::warn!(error = %error, "grpc-nats-micro: failed to encode error reply"); - return; - } + 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()); - if let Err(source) = client.publish_with_headers(reply, headers, encoded.body).await { - tracing::warn!(error = %source, "grpc-nats-micro: failed to publish error reply"); + 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/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_error_code/tests.rs index ff9ad02e20..d425e2838c 100644 --- 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 @@ -30,7 +30,11 @@ 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 }) + Err(ServiceErrorCodeError::NotAnInteger { header: header.clone() }) + ); + assert_eq!( + ServiceErrorCodeError::NotAnInteger { header }.to_string(), + "service error code RESOURCE_EXHAUSTED is not an integer" ); } 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 index b8e2d131e6..2ca977c5a7 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_fault/tests.rs @@ -36,3 +36,13 @@ fn named_constructors_carry_their_code() { ); 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/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs index ded6f45e10..16cd27a2ce 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs @@ -32,3 +32,9 @@ fn rejects_characters_outside_the_protobuf_identifier_grammar() { Err(ServiceNameError::InvalidCharacter('-')) ); } + +#[test] +fn rejects_a_name_over_the_subject_token_budget() { + let long = "E".repeat(129); + assert_eq!(ServiceName::new(&long), Err(ServiceNameError::TooLong(129))); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index a5c00443d3..0c0c29ff3a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -117,3 +117,6 @@ pub enum ReplyError { #[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..065731f494 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs @@ -0,0 +1,131 @@ +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" + ); +} + +#[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(_))); +} 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 index b92b76ea62..e638474d56 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs @@ -46,3 +46,9 @@ fn rejects_whitespace() { Err(SubjectPrefixError::InvalidCharacter(' ')) ); } + +#[test] +fn rejects_a_prefix_over_the_subject_token_budget() { + let long = "e".repeat(129); + assert_eq!(SubjectPrefix::new(&long), Err(SubjectPrefixError::TooLong(129))); +} diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index 14bc8dedb2..f457094162 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -11,6 +11,7 @@ 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::{ @@ -25,10 +26,13 @@ use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, Ser const SUBJECT_PREFIX: &str = "echo.v1"; const SERVICE_NAME: &str = "EchoService"; const SERVICE_VERSION: &str = "0.1.0"; +const SERVICE_DESCRIPTION: &str = "Echoes what it is told"; const SAY_METHOD: &str = "Say"; const FAIL_METHOD: &str = "Fail"; 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_SETTLE: Duration = Duration::from_millis(100); const FAIL_DETAIL_REASON: &str = "ECHO_FAIL"; const FAIL_DETAIL_DOMAIN: &str = "grpc-nats-micro.conformance"; @@ -142,6 +146,7 @@ fn echo_service_binding() -> ServiceBinding { SERVICE_VERSION, SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), ) + .with_description(SERVICE_DESCRIPTION) .with_method(method(SAY_METHOD)) .expect("derive Say subject") .with_method(method(FAIL_METHOD)) @@ -364,6 +369,7 @@ async fn discovery_names_endpoints_after_rpc_methods() { .expect("$SRV.INFO responded"); let info: serde_json::Value = serde_json::from_slice(&response.payload).expect("decode $SRV.INFO record"); + assert_eq!(info["description"].as_str(), Some(SERVICE_DESCRIPTION)); let mut names: Vec<&str> = info["endpoints"] .as_array() .expect("$SRV.INFO carries endpoints") @@ -448,3 +454,43 @@ async fn a_handler_count_mismatch_is_rejected_before_startup() { } )); } + +/// 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 = NatsServerProcess::spawn().await; + let client = connect(&server.url()).await.expect("connect to nats-server"); + 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"); + tokio::time::sleep(STOPPED_SERVICE_SETTLE).await; + client.flush().await.expect("flush the unsubscribes"); + + let endpoint = binding + .endpoints() + .iter() + .find(|endpoint| endpoint.method_name().as_str() == SAY_METHOD) + .expect("Say endpoint registered"); + let request = SayRequest { + message: Some("hello".to_string()), + }; + let error = grpc_nats_micro::client::request::<_, SayRequest, SayResponse>( + &client, + endpoint, + ContentType::Protobuf, + &request, + STOPPED_SERVICE_TIMEOUT, + ) + .await + .expect_err("a stopped service must not respond"); + + assert!( + matches!(error, RequestError::Transport { .. }), + "expected no responder, got {error:?}" + ); +} From 02e218cb5ed6699143470027543e97f497d02b4f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 03:42:23 -0400 Subject: [PATCH 08/14] fix(grpc-nats-micro): make an unregistrable service unrepresentable NATS micro admits only semantic versions, so the reference annotation's version could not actually start a service. A reply that named an encoding this binding does not speak was decoded as whatever the caller asked for instead of being reported. Signed-off-by: Yordis Prieto --- proto/trogonai/grpc_nats_micro/v1/echo.proto | 2 +- rsworkspace/Cargo.lock | 1 + rsworkspace/Cargo.toml | 1 + .../platform/grpc-nats-micro/Cargo.toml | 1 + .../platform/grpc-nats-micro/src/binding.rs | 9 ++--- .../grpc-nats-micro/src/binding/tests.rs | 3 +- .../grpc-nats-micro/src/client/tests.rs | 3 +- .../grpc-nats-micro/src/endpoint_subject.rs | 14 ++++++-- .../src/endpoint_subject/tests.rs | 15 ++++++++ .../platform/grpc-nats-micro/src/lib.rs | 2 ++ .../platform/grpc-nats-micro/src/server.rs | 2 +- .../grpc-nats-micro/src/service_version.rs | 36 +++++++++++++++++++ .../src/service_version/tests.rs | 20 +++++++++++ .../grpc-nats-micro/src/status_codec.rs | 20 +++++++---- .../grpc-nats-micro/src/status_codec/tests.rs | 17 +++++++++ .../grpc-nats-micro/tests/echo_conformance.rs | 6 ++-- 16 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs diff --git a/proto/trogonai/grpc_nats_micro/v1/echo.proto b/proto/trogonai/grpc_nats_micro/v1/echo.proto index e209c9788e..99d767691c 100644 --- a/proto/trogonai/grpc_nats_micro/v1/echo.proto +++ b/proto/trogonai/grpc_nats_micro/v1/echo.proto @@ -14,7 +14,7 @@ import "trogon/nats/micro/v1alpha1/options.proto"; // 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"}; + option (trogon.nats.micro.v1alpha1.service) = {version: "1.0.0"}; // Say returns the request message unchanged. rpc Say(SayRequest) returns (SayResponse); diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 8000d7333a..ef6c0e054e 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -2798,6 +2798,7 @@ dependencies = [ "buffa-types", "bytes", "futures-util", + "semver", "serde", "serde_json", "thiserror 2.0.19", diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index f29f6b988b..8e2750d7b5 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -113,6 +113,7 @@ teloxide = { version = "=0.14.1", 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.2", default-features = false } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml index 862db29b2b..e9f9d305cc 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -13,6 +13,7 @@ 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 } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs index c34060fd88..533b207622 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -4,6 +4,7 @@ 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 @@ -38,17 +39,17 @@ impl EndpointBinding { #[derive(Debug, Clone)] pub struct ServiceBinding { name: ServiceName, - version: String, + version: ServiceVersion, description: Option, subject_prefix: SubjectPrefix, endpoints: Vec, } impl ServiceBinding { - pub fn new(name: ServiceName, version: impl Into, subject_prefix: SubjectPrefix) -> Self { + pub fn new(name: ServiceName, version: ServiceVersion, subject_prefix: SubjectPrefix) -> Self { Self { name, - version: version.into(), + version, description: None, subject_prefix, endpoints: Vec::new(), @@ -73,7 +74,7 @@ impl ServiceBinding { &self.name } - pub fn version(&self) -> &str { + pub const fn version(&self) -> &ServiceVersion { &self.version } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs index 396361ab1a..fc54d2d2e3 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -1,6 +1,7 @@ use super::ServiceBinding; use crate::method_name::MethodName; use crate::service_name::ServiceName; +use crate::service_version::ServiceVersion; use crate::subject_prefix::SubjectPrefix; const SUBJECT_PREFIX: &str = "echo.v1"; @@ -8,7 +9,7 @@ const SUBJECT_PREFIX: &str = "echo.v1"; fn binding() -> ServiceBinding { ServiceBinding::new( ServiceName::new("EchoService").expect("valid service name"), - "0.1.0", + ServiceVersion::new("1.0.0").expect("valid service version"), SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), ) .with_method(MethodName::new("Say").expect("valid method name")) diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs index 407eaf2754..3cfacc6633 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs @@ -13,6 +13,7 @@ use crate::constants::HEADER_ERROR_CODE; use crate::content_type::ContentType; use crate::method_name::MethodName; use crate::service_name::ServiceName; +use crate::service_version::ServiceVersion; use crate::subject_prefix::SubjectPrefix; const SAY_SUBJECT: &str = "echo.v1.EchoService.Say"; @@ -21,7 +22,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_millis(50); fn binding() -> ServiceBinding { ServiceBinding::new( ServiceName::new("EchoService").expect("valid service name"), - "0.1.0", + ServiceVersion::new("1.0.0").expect("valid service version"), SubjectPrefix::new("echo.v1").expect("valid subject prefix"), ) .with_method(MethodName::new("Say").expect("valid method name")) diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs index 4a35e07f04..5828cfbd04 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject.rs @@ -11,10 +11,16 @@ 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:?} is not a conformant published subject")] +#[error("derived endpoint subject {subject_prefix}.{service_name}.{method_name} is not a conformant published subject")] pub struct EndpointSubjectError { - pub subject: String, + pub subject_prefix: SubjectPrefix, + pub service_name: ServiceName, + pub method_name: MethodName, #[source] pub source: SubjectViolationError, } @@ -36,7 +42,9 @@ impl EndpointSubject { ) -> Result { let subject = format!("{subject_prefix}.{service_name}.{method_name}"); validate_published_subject(&subject).map_err(|source| EndpointSubjectError { - subject: subject.clone(), + subject_prefix: subject_prefix.clone(), + service_name: service_name.clone(), + method_name: method_name.clone(), source, })?; Ok(Self(subject.into())) 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 index bc0e3c159f..f2b71138c0 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs @@ -35,3 +35,18 @@ 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 index e12b1870e0..fe35cd5654 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -22,6 +22,7 @@ pub mod service_error_code; pub mod service_error_code_input; pub mod service_fault; pub mod service_name; +pub mod service_version; pub mod status_codec; pub mod subject_prefix; @@ -35,5 +36,6 @@ 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_version::{ServiceVersion, ServiceVersionError}; pub use status_codec::{EncodedReply, Outcome, ReplyError, ServiceError}; pub use subject_prefix::{SubjectPrefix, SubjectPrefixError}; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index b8c79bab9c..7ceece9cae 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -82,7 +82,7 @@ pub async fn serve( builder = builder.description(description); } let service = builder - .start(binding.name().as_str(), binding.version()) + .start(binding.name().as_str(), binding.version().as_str()) .await .map_err(ServeError::Start)?; 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..431165dca9 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs @@ -0,0 +1,36 @@ +//! The registered NATS micro service's version (ADR 0016 §1). + +/// 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 new(value: impl AsRef) -> Result { + let value = value.as_ref(); + 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..f5901013a0 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs @@ -0,0 +1,20 @@ +use super::ServiceVersion; + +#[test] +fn accepts_a_semantic_version() { + let version = ServiceVersion::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::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::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/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index 0c0c29ff3a..2735ae6613 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -82,17 +82,23 @@ impl ServiceError { /// disagreement, and the body is decoded as the complete [`Status`]. Absent /// the header, the body is decoded as `Resp`. /// -/// `requested` is only a fallback: 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. +/// `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 content_type = headers + let declared = headers .and_then(|headers| headers.get(HEADER_CONTENT_TYPE)) - .and_then(|value| ContentType::from_input(&ContentTypeInput::new(value.as_str()))) - .unwrap_or(requested); + .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 { @@ -112,6 +118,8 @@ where 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)] 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 index 065731f494..f87dcfc4df 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs @@ -129,3 +129,20 @@ fn an_undecodable_error_body_is_reported() { 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/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index f457094162..fabb200bbf 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -15,7 +15,7 @@ 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, EndpointHandler, MethodName, ServiceBinding, ServiceFault, ServiceName, SubjectPrefix, + ContentType, EndpointHandler, MethodName, ServiceBinding, ServiceFault, ServiceName, ServiceVersion, SubjectPrefix, }; use tokio::process::{Child, Command}; use trogon_nats::{NatsConfig, RequestClient}; @@ -25,7 +25,7 @@ use trogonai_proto::nats::micro::v1alpha1::{ContentType as ProtoContentType, Ser const SUBJECT_PREFIX: &str = "echo.v1"; const SERVICE_NAME: &str = "EchoService"; -const SERVICE_VERSION: &str = "0.1.0"; +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"; @@ -143,7 +143,7 @@ impl EndpointHandler for FailHandler { fn echo_service_binding() -> ServiceBinding { ServiceBinding::new( ServiceName::new(SERVICE_NAME).expect("valid service name"), - SERVICE_VERSION, + ServiceVersion::new(SERVICE_VERSION).expect("valid service version"), SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), ) .with_description(SERVICE_DESCRIPTION) From 36238878e1b4e4e4be4935717396e0f20238a67b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 22:48:11 -0400 Subject: [PATCH 09/14] refactor(grpc-nats-micro): source the conformance server from testcontainers This suite was the only NATS test in the workspace that needed a server binary on PATH, which forced a toolchain pin to keep CI working while every other integration test already got its server from a container. Signed-off-by: Yordis Prieto --- .mise.toml | 1 - .../platform/grpc-nats-micro/Cargo.toml | 2 +- .../grpc-nats-micro/tests/echo_conformance.rs | 84 ++++--------------- .../platform/trogon-nats/src/test_support.rs | 60 ++++++++++--- 4 files changed, 66 insertions(+), 81 deletions(-) diff --git a/.mise.toml b/.mise.toml index 4a6f9f6234..37436d91b1 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,7 +1,6 @@ [tools] buf = "1.70.0" go = "1.26.5" -"ubi:nats-io/nats-server" = { version = "2.14.5", exe = "nats-server" } "ubi:open-telemetry/weaver" = { version = "0.24.2", exe = "weaver" } "cargo:cargo-dylint" = "6.0.1" "cargo:dylint-link" = "6.0.1" diff --git a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml index e9f9d305cc..3817710ead 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml +++ b/rsworkspace/crates/platform/grpc-nats-micro/Cargo.toml @@ -24,5 +24,5 @@ trogonai-proto = { workspace = true, features = ["grpc-nats-micro"] } [dev-dependencies] buffa-types = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "time"] } +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/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index fabb200bbf..bf0f3a7464 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -1,9 +1,8 @@ //! ADR 0016 conformance: an Echo/Fail service registered through -//! [`grpc_nats_micro::serve`] against a real `nats-server` process. +//! [`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::net::TcpListener; use std::pin::Pin; use std::time::Duration; @@ -17,7 +16,7 @@ use grpc_nats_micro::status_codec::ReplyError; use grpc_nats_micro::{ ContentType, EndpointHandler, MethodName, ServiceBinding, ServiceFault, ServiceName, ServiceVersion, SubjectPrefix, }; -use tokio::process::{Child, Command}; +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}; @@ -36,58 +35,11 @@ const STOPPED_SERVICE_SETTLE: Duration = Duration::from_millis(100); const FAIL_DETAIL_REASON: &str = "ECHO_FAIL"; const FAIL_DETAIL_DOMAIN: &str = "grpc-nats-micro.conformance"; -struct NatsServerProcess { - child: Child, - port: u16, -} - -impl NatsServerProcess { - async fn spawn() -> Self { - let port = free_port(); - let child = Command::new("nats-server") - .args(["-p", &port.to_string(), "-a", "127.0.0.1"]) - .kill_on_drop(true) - .spawn() - .expect("spawn nats-server; is it on PATH?"); - let process = Self { child, port }; - process.wait_until_ready().await; - process - } - - async fn wait_until_ready(&self) { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - if connect(&self.url()).await.is_ok() { - return; - } - if tokio::time::Instant::now() >= deadline { - panic!("nats-server did not become ready on port {}", self.port); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - } - - fn url(&self) -> String { - format!("127.0.0.1:{}", self.port) - } -} - -impl Drop for NatsServerProcess { - fn drop(&mut self) { - let _ = self.child.start_kill(); - } -} - -async fn connect(url: &str) -> Result { - trogon_nats::connect(&NatsConfig::from_url(url), CONNECT_TIMEOUT).await -} - -fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind ephemeral port") - .local_addr() - .expect("local addr") - .port() +/// 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; @@ -157,12 +109,12 @@ fn method(name: &str) -> MethodName { MethodName::new(name).expect("valid method name") } -/// Keeps the spawned `nats-server` process, 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`]. +/// 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: NatsServerProcess, + _server: CoreTestServer, client: async_nats::Client, binding: ServiceBinding, _service: Service, @@ -173,8 +125,8 @@ async fn start_fixture() -> EchoFixture { } async fn start_fixture_with_policy(content_type_policy: ServiceOptions) -> EchoFixture { - let server = NatsServerProcess::spawn().await; - let client = connect(&server.url()).await.expect("connect to nats-server"); + 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)]; @@ -433,8 +385,8 @@ async fn fail_reports_status_over_json() { /// silently truncated by `zip`, leaving declared methods unserved. #[tokio::test] async fn a_handler_count_mismatch_is_rejected_before_startup() { - let server = NatsServerProcess::spawn().await; - let client = connect(&server.url()).await.expect("connect to nats-server"); + 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( @@ -459,8 +411,8 @@ async fn a_handler_count_mismatch_is_rejected_before_startup() { /// 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 = NatsServerProcess::spawn().await; - let client = connect(&server.url()).await.expect("connect to nats-server"); + 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) 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 } } From 38203b5019f20e1411f5584d48fb748ada6671b6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 23:01:10 -0400 Subject: [PATCH 10/14] chore(rsworkspace): restore the lockfile entries the merge dropped The merge resolution took main's Cargo.lock wholesale, and main's copy is missing entries for crates that are workspace members there, so the merged lockfile no longer described the tree it shipped with. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 985 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 972 insertions(+), 13 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 069c6897bc..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" @@ -2388,7 +2813,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "trogon-nats", @@ -2435,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" @@ -2449,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" @@ -2476,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" @@ -3165,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" @@ -3295,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" @@ -3338,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" @@ -3375,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" @@ -3673,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" @@ -3757,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" @@ -4975,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" @@ -5128,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" @@ -5209,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" @@ -5292,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]] @@ -5339,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" @@ -5716,6 +6434,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5763,7 +6482,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -5802,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" @@ -5977,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", @@ -6091,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" @@ -6358,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" @@ -6409,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" @@ -6552,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" @@ -6574,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" @@ -6586,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" @@ -6712,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" @@ -7272,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" @@ -7915,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" From da9571c9eee49c31f85f140652fac5a2adb978a3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 2 Sep 2026 11:23:14 -0400 Subject: [PATCH 11/14] refactor(grpc-nats-micro): make binding identifiers and error headers unfalsifiable The identifier constructors took any string-like value, so a caller could not tell validated text from text that had merely been passed along, and the error reply put a fault message straight into a NATS header, where a message spanning lines aborts the dispatch task instead of reporting anything. Signed-off-by: Yordis Prieto --- .../grpc-nats-micro/src/binding/tests.rs | 12 ++-- .../grpc-nats-micro/src/client/tests.rs | 12 ++-- .../src/endpoint_subject/tests.rs | 9 ++- .../platform/grpc-nats-micro/src/lib.rs | 8 +++ .../grpc-nats-micro/src/method_name.rs | 6 +- .../grpc-nats-micro/src/method_name/tests.rs | 27 +++++-- .../grpc-nats-micro/src/method_name_input.rs | 25 +++++++ .../grpc-nats-micro/src/service_name.rs | 6 +- .../grpc-nats-micro/src/service_name/tests.rs | 27 +++++-- .../grpc-nats-micro/src/service_name_input.rs | 25 +++++++ .../grpc-nats-micro/src/service_version.rs | 6 +- .../src/service_version/tests.rs | 9 ++- .../src/service_version_input.rs | 26 +++++++ .../grpc-nats-micro/src/status_codec.rs | 32 ++++++++- .../grpc-nats-micro/src/status_codec/tests.rs | 27 +++++++ .../grpc-nats-micro/src/subject_prefix.rs | 6 +- .../src/subject_prefix/tests.rs | 25 ++++--- .../src/subject_prefix_input.rs | 25 +++++++ .../grpc-nats-micro/tests/echo_conformance.rs | 72 +++++++++++++------ 19 files changed, 318 insertions(+), 67 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs index fc54d2d2e3..c5e139080f 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -1,18 +1,22 @@ use super::ServiceBinding; 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::new("EchoService").expect("valid service name"), - ServiceVersion::new("1.0.0").expect("valid service version"), - SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), + 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::new("Say").expect("valid method name")) + .with_method(MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name")) .expect("derive the Say subject") } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs index 3cfacc6633..86f50463f6 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs @@ -12,20 +12,24 @@ use crate::binding::{EndpointBinding, ServiceBinding}; use crate::constants::HEADER_ERROR_CODE; use crate::content_type::ContentType; 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::new("EchoService").expect("valid service name"), - ServiceVersion::new("1.0.0").expect("valid service version"), - SubjectPrefix::new("echo.v1").expect("valid subject prefix"), + 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::new("Say").expect("valid method name")) + .with_method(MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name")) .expect("derive the Say subject") } 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 index f2b71138c0..7eda155886 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/endpoint_subject/tests.rs @@ -1,13 +1,16 @@ 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::new(prefix).expect("valid prefix"), - &ServiceName::new("EchoService").expect("valid service name"), - &MethodName::new("Say").expect("valid method name"), + &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"), ) } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs index fe35cd5654..a6648393ab 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -17,25 +17,33 @@ pub mod content_type; pub mod content_type_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 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 index 8d9639002d..646845abc8 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name.rs @@ -3,6 +3,8 @@ 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 { @@ -36,8 +38,8 @@ impl From for MethodNameError { pub struct MethodName(NatsToken); impl MethodName { - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref(); + pub fn from_input(input: &MethodNameInput) -> Result { + let value = input.as_str(); let token = NatsToken::new(value)?; let mut characters = value.chars(); 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 index 87882cff8a..c955c5738a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name/tests.rs @@ -1,34 +1,44 @@ use super::{MethodName, MethodNameError}; +use crate::method_name_input::MethodNameInput; #[test] fn accepts_a_protobuf_method_name() { - let method = MethodName::new("Say").expect("protobuf method name is valid"); + 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::new(""), Err(MethodNameError::Empty)); + assert_eq!( + MethodName::from_input(&MethodNameInput::new("")), + Err(MethodNameError::Empty) + ); } #[test] fn rejects_a_leading_digit() { - assert_eq!(MethodName::new("2Say"), Err(MethodNameError::LeadingCharacter('2'))); + assert_eq!( + MethodName::from_input(&MethodNameInput::new("2Say")), + Err(MethodNameError::LeadingCharacter('2')) + ); } #[test] fn rejects_subject_separators_and_wildcards() { assert_eq!( - MethodName::new("Say.Again"), + MethodName::from_input(&MethodNameInput::new("Say.Again")), Err(MethodNameError::InvalidCharacter('.')) ); - assert_eq!(MethodName::new("Say>"), 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::new("Say-Again"), + MethodName::from_input(&MethodNameInput::new("Say-Again")), Err(MethodNameError::InvalidCharacter('-')) ); } @@ -36,5 +46,8 @@ fn rejects_characters_outside_the_protobuf_identifier_grammar() { #[test] fn rejects_a_name_over_the_subject_token_budget() { let long = "S".repeat(129); - assert_eq!(MethodName::new(&long), Err(MethodNameError::TooLong(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..3587f5d343 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs @@ -0,0 +1,25 @@ +//! 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 + } +} + +impl std::fmt::Display for MethodNameInput { + 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_name.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs index 6f6d8d27c8..8037df631c 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name.rs @@ -3,6 +3,8 @@ 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 { @@ -36,8 +38,8 @@ impl From for ServiceNameError { pub struct ServiceName(NatsToken); impl ServiceName { - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref(); + pub fn from_input(input: &ServiceNameInput) -> Result { + let value = input.as_str(); let token = NatsToken::new(value)?; let mut characters = value.chars(); 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 index 16cd27a2ce..ff32ffdbbe 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name/tests.rs @@ -1,34 +1,44 @@ use super::{ServiceName, ServiceNameError}; +use crate::service_name_input::ServiceNameInput; #[test] fn accepts_a_protobuf_service_name() { - let name = ServiceName::new("EchoService").expect("protobuf service name is valid"); + 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::new(""), Err(ServiceNameError::Empty)); + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("")), + Err(ServiceNameError::Empty) + ); } #[test] fn rejects_a_leading_digit() { - assert_eq!(ServiceName::new("1Echo"), Err(ServiceNameError::LeadingCharacter('1'))); + assert_eq!( + ServiceName::from_input(&ServiceNameInput::new("1Echo")), + Err(ServiceNameError::LeadingCharacter('1')) + ); } #[test] fn rejects_subject_separators_and_wildcards() { assert_eq!( - ServiceName::new("echo.v1"), + ServiceName::from_input(&ServiceNameInput::new("echo.v1")), Err(ServiceNameError::InvalidCharacter('.')) ); - assert_eq!(ServiceName::new("Echo*"), 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::new("Echo-Service"), + ServiceName::from_input(&ServiceNameInput::new("Echo-Service")), Err(ServiceNameError::InvalidCharacter('-')) ); } @@ -36,5 +46,8 @@ fn rejects_characters_outside_the_protobuf_identifier_grammar() { #[test] fn rejects_a_name_over_the_subject_token_budget() { let long = "E".repeat(129); - assert_eq!(ServiceName::new(&long), Err(ServiceNameError::TooLong(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..34c145be06 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs @@ -0,0 +1,25 @@ +//! 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 + } +} + +impl std::fmt::Display for ServiceNameInput { + 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_version.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs index 431165dca9..34cef65e61 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version.rs @@ -1,5 +1,7 @@ //! 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")] @@ -15,8 +17,8 @@ pub struct ServiceVersionError(#[from] semver::Error); pub struct ServiceVersion(Box); impl ServiceVersion { - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref(); + pub fn from_input(input: &ServiceVersionInput) -> Result { + let value = input.as_str(); semver::Version::parse(value)?; Ok(Self(value.into())) } 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 index f5901013a0..300735cc34 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version/tests.rs @@ -1,20 +1,23 @@ use super::ServiceVersion; +use crate::service_version_input::ServiceVersionInput; #[test] fn accepts_a_semantic_version() { - let version = ServiceVersion::new("1.0.0").expect("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::new("1.0.0-rc.1+build.7").expect("a semantic 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::new("1").expect_err("a bare major is not a semantic version"); + 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..18c3819330 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs @@ -0,0 +1,26 @@ +//! 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 + } +} + +impl std::fmt::Display for ServiceVersionInput { + 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/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index 2735ae6613..fb527fdb7a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -2,7 +2,9 @@ //! `Nats-Service-Error-Code` is present, and on error the body is one //! complete `google.rpc.Status` encoded per the negotiated [`ContentType`]. -use async_nats::HeaderMap; +use std::str::FromStr as _; + +use async_nats::{HeaderMap, HeaderValue}; use bytes::Bytes; use thiserror::Error; use trogonai_proto::google::rpc::Status; @@ -37,7 +39,9 @@ pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result { let body = content_type.encode(fault.status())?; let mut headers = HeaderMap::new(); - headers.insert(HEADER_ERROR, fault.message()); + 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, @@ -47,6 +51,30 @@ pub fn encode_reply(outcome: Outcome, content_type: ContentType) -> Result` +/// 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, + "grpc-nats-micro: fault message is not a valid {HEADER_ERROR} 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 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 index f87dcfc4df..f802112627 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec/tests.rs @@ -50,6 +50,33 @@ fn an_error_reply_carries_the_code_and_message_headers() { ); } +/// 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 { diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs index deee906496..3b16904af4 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix.rs @@ -3,6 +3,8 @@ 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 { @@ -33,8 +35,8 @@ impl From for SubjectPrefixError { pub struct SubjectPrefix(DottedNatsToken); impl SubjectPrefix { - pub fn new(value: impl AsRef) -> Result { - DottedNatsToken::new(value).map(Self).map_err(Into::into) + pub fn from_input(input: &SubjectPrefixInput) -> Result { + DottedNatsToken::new(input.as_str()).map(Self).map_err(Into::into) } pub fn as_str(&self) -> &str { 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 index e638474d56..b6fa999138 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix/tests.rs @@ -1,24 +1,28 @@ use super::{SubjectPrefix, SubjectPrefixError}; +use crate::subject_prefix_input::SubjectPrefixInput; #[test] fn accepts_a_dotted_namespace() { - let prefix = SubjectPrefix::new("echo.v1").expect("dotted prefix is valid"); + 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::new(""), Err(SubjectPrefixError::Empty)); + assert_eq!( + SubjectPrefix::from_input(&SubjectPrefixInput::new("")), + Err(SubjectPrefixError::Empty) + ); } #[test] fn rejects_wildcards() { assert_eq!( - SubjectPrefix::new("echo.*"), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.*")), Err(SubjectPrefixError::InvalidCharacter('*')) ); assert_eq!( - SubjectPrefix::new("echo.>"), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.>")), Err(SubjectPrefixError::InvalidCharacter('>')) ); } @@ -26,15 +30,15 @@ fn rejects_wildcards() { #[test] fn rejects_malformed_dots() { assert_eq!( - SubjectPrefix::new(".echo"), + SubjectPrefix::from_input(&SubjectPrefixInput::new(".echo")), Err(SubjectPrefixError::InvalidCharacter('.')) ); assert_eq!( - SubjectPrefix::new("echo."), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo.")), Err(SubjectPrefixError::InvalidCharacter('.')) ); assert_eq!( - SubjectPrefix::new("echo..v1"), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo..v1")), Err(SubjectPrefixError::InvalidCharacter('.')) ); } @@ -42,7 +46,7 @@ fn rejects_malformed_dots() { #[test] fn rejects_whitespace() { assert_eq!( - SubjectPrefix::new("echo v1"), + SubjectPrefix::from_input(&SubjectPrefixInput::new("echo v1")), Err(SubjectPrefixError::InvalidCharacter(' ')) ); } @@ -50,5 +54,8 @@ fn rejects_whitespace() { #[test] fn rejects_a_prefix_over_the_subject_token_budget() { let long = "e".repeat(129); - assert_eq!(SubjectPrefix::new(&long), Err(SubjectPrefixError::TooLong(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..e99a488ed0 --- /dev/null +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs @@ -0,0 +1,25 @@ +//! 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 + } +} + +impl std::fmt::Display for SubjectPrefixInput { + 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/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index bf0f3a7464..6a7ff677b2 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -14,7 +14,8 @@ 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, EndpointHandler, MethodName, ServiceBinding, ServiceFault, ServiceName, ServiceVersion, SubjectPrefix, + ContentType, EndpointBinding, EndpointHandler, MethodName, MethodNameInput, ServiceBinding, ServiceFault, + ServiceName, ServiceNameInput, ServiceVersion, ServiceVersionInput, SubjectPrefix, SubjectPrefixInput, }; use trogon_nats::test_support::CoreTestServer; use trogon_nats::{NatsConfig, RequestClient}; @@ -31,7 +32,8 @@ const FAIL_METHOD: &str = "Fail"; 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_SETTLE: Duration = Duration::from_millis(100); +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"; @@ -94,9 +96,9 @@ impl EndpointHandler for FailHandler { fn echo_service_binding() -> ServiceBinding { ServiceBinding::new( - ServiceName::new(SERVICE_NAME).expect("valid service name"), - ServiceVersion::new(SERVICE_VERSION).expect("valid service version"), - SubjectPrefix::new(SUBJECT_PREFIX).expect("valid subject prefix"), + 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_method(method(SAY_METHOD)) @@ -106,7 +108,7 @@ fn echo_service_binding() -> ServiceBinding { } fn method(name: &str) -> MethodName { - MethodName::new(name).expect("valid method name") + MethodName::from_input(&MethodNameInput::new(name)).expect("valid method name") } /// Keeps the NATS container, the client, the service registration, and the @@ -420,29 +422,59 @@ async fn stopping_the_service_leaves_its_subjects_without_a_responder() { .expect("start EchoService"); service.stop().await.expect("stop EchoService"); - tokio::time::sleep(STOPPED_SERVICE_SETTLE).await; - client.flush().await.expect("flush the unsubscribes"); let endpoint = binding .endpoints() .iter() .find(|endpoint| endpoint.method_name().as_str() == SAY_METHOD) .expect("Say endpoint registered"); - let request = SayRequest { - message: Some("hello".to_string()), - }; - let error = grpc_nats_micro::client::request::<_, SayRequest, SayResponse>( - &client, - endpoint, - ContentType::Protobuf, - &request, - STOPPED_SERVICE_TIMEOUT, - ) - .await - .expect_err("a stopped service must not respond"); + 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; + } +} From 15d2ed4bac4516218090009f58d7d5fa4e983168 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 2 Sep 2026 12:07:49 -0400 Subject: [PATCH 12/14] feat(grpc-nats-micro): carry annotated metadata into discovery ADR 0016 section 1 makes the service and method metadata maps part of the discovery record, so an annotation that never reached $SRV.INFO left callers unable to see the very thing the annotation was written to advertise. Signed-off-by: Yordis Prieto --- .../platform/grpc-nats-micro/src/binding.rs | 47 ++++++++++- .../grpc-nats-micro/src/binding/tests.rs | 6 +- .../grpc-nats-micro/src/client/tests.rs | 6 +- .../grpc-nats-micro/src/discovery_metadata.rs | 42 ++++++++++ .../src/discovery_metadata/tests.rs | 28 +++++++ .../src/discovery_metadata_input.rs | 31 ++++++++ .../platform/grpc-nats-micro/src/lib.rs | 4 + .../platform/grpc-nats-micro/src/server.rs | 16 ++-- .../grpc-nats-micro/tests/echo_conformance.rs | 79 +++++++++++++++---- 9 files changed, 234 insertions(+), 25 deletions(-) create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata/tests.rs create mode 100644 rsworkspace/crates/platform/grpc-nats-micro/src/discovery_metadata_input.rs diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs index 533b207622..8222ce283c 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding.rs @@ -1,6 +1,7 @@ //! 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; @@ -13,6 +14,7 @@ use crate::subject_prefix::SubjectPrefix; pub struct EndpointBinding { method_name: MethodName, subject: EndpointSubject, + metadata: DiscoveryMetadata, } impl EndpointBinding { @@ -20,9 +22,14 @@ impl EndpointBinding { 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 }) + Ok(Self { + method_name, + subject, + metadata, + }) } pub fn method_name(&self) -> &MethodName { @@ -32,6 +39,12 @@ impl EndpointBinding { 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 @@ -41,6 +54,7 @@ pub struct ServiceBinding { name: ServiceName, version: ServiceVersion, description: Option, + metadata: DiscoveryMetadata, subject_prefix: SubjectPrefix, endpoints: Vec, } @@ -51,6 +65,7 @@ impl ServiceBinding { name, version, description: None, + metadata: DiscoveryMetadata::default(), subject_prefix, endpoints: Vec::new(), } @@ -62,11 +77,29 @@ impl ServiceBinding { 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. - pub fn with_method(mut self, method_name: MethodName) -> Result { - self.endpoints - .push(EndpointBinding::new(&self.subject_prefix, &self.name, method_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) } @@ -82,6 +115,12 @@ impl ServiceBinding { 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 } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs index c5e139080f..9cf444044a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -1,4 +1,5 @@ use super::ServiceBinding; +use crate::discovery_metadata::DiscoveryMetadata; use crate::method_name::MethodName; use crate::method_name_input::MethodNameInput; use crate::service_name::ServiceName; @@ -16,7 +17,10 @@ fn binding() -> ServiceBinding { 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")) + .with_method( + MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + DiscoveryMetadata::default(), + ) .expect("derive the Say subject") } diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs index 86f50463f6..c7d5dbfca6 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/client/tests.rs @@ -11,6 +11,7 @@ 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; @@ -29,7 +30,10 @@ fn binding() -> ServiceBinding { 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")) + .with_method( + MethodName::from_input(&MethodNameInput::new("Say")).expect("valid method name"), + DiscoveryMetadata::default(), + ) .expect("derive the Say subject") } 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/lib.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs index a6648393ab..f019843079 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/lib.rs @@ -15,6 +15,8 @@ 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; @@ -33,6 +35,8 @@ 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; diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs index 7ceece9cae..ba0097811e 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/server.rs @@ -81,6 +81,12 @@ pub async fn serve( 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 @@ -93,11 +99,11 @@ pub async fn serve( // 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 registration = service - .endpoint_builder() - .name(endpoint.method_name().as_str()) - .add(subject.clone()) - .await; + 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(); diff --git a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs index 6a7ff677b2..3f28e4a40a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/tests/echo_conformance.rs @@ -14,8 +14,9 @@ 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, EndpointBinding, EndpointHandler, MethodName, MethodNameInput, ServiceBinding, ServiceFault, - ServiceName, ServiceNameInput, ServiceVersion, ServiceVersionInput, SubjectPrefix, SubjectPrefixInput, + 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}; @@ -29,6 +30,10 @@ 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); @@ -101,9 +106,14 @@ fn echo_service_binding() -> ServiceBinding { SubjectPrefix::from_input(&SubjectPrefixInput::new(SUBJECT_PREFIX)).expect("valid subject prefix"), ) .with_description(SERVICE_DESCRIPTION) - .with_method(method(SAY_METHOD)) + .with_metadata(metadata([(SERVICE_METADATA_KEY, SERVICE_METADATA_VALUE)])) + .with_method(method(SAY_METHOD), DiscoveryMetadata::default()) .expect("derive Say subject") - .with_method(method(FAIL_METHOD)) + // 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") } @@ -111,6 +121,10 @@ 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 @@ -306,23 +320,26 @@ async fn fail_details_reach_the_client_over_json() { assert_fail_details_reach_the_client(ContentType::Json).await; } -/// 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; - +/// 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, - fixture - .client - .request(format!("$SRV.INFO.{SERVICE_NAME}"), bytes::Bytes::new()), + client.request(format!("$SRV.INFO.{SERVICE_NAME}"), bytes::Bytes::new()), ) .await .expect("$SRV.INFO did not time out") .expect("$SRV.INFO responded"); - let info: serde_json::Value = serde_json::from_slice(&response.payload).expect("decode $SRV.INFO record"); + 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() @@ -334,6 +351,40 @@ async fn discovery_names_endpoints_after_rpc_methods() { 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`. From 1741d8286f1a8a54ec767f0be0e6e263cfd307d8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 2 Sep 2026 13:01:50 -0400 Subject: [PATCH 13/14] fix(grpc-nats-micro): keep the fault-header warning queryable by field The repo policy lint reserves log messages for prose so the header a fault message was rejected for stays a field a query can filter on. Signed-off-by: Yordis Prieto --- .../crates/platform/grpc-nats-micro/src/status_codec.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs index fb527fdb7a..31ab407289 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/status_codec.rs @@ -68,7 +68,8 @@ fn describe(message: &str) -> Option { .inspect_err(|error| { tracing::warn!( error = %error, - "grpc-nats-micro: fault message is not a valid {HEADER_ERROR} value; \ + header = HEADER_ERROR, + "grpc-nats-micro: fault message is not a valid header value; \ replying with the status body alone" ); }) From fbd811414580a2b3048a9cc4ccb58c5f2fdbe848 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 2 Sep 2026 13:01:51 -0400 Subject: [PATCH 14/14] refactor(grpc-nats-micro): drop the unexercised boundary-type rendering Nothing renders a boundary type, so the impls only shielded the derivation failure path from ever being proven. Signed-off-by: Yordis Prieto --- .../grpc-nats-micro/src/binding/tests.rs | 22 +++++++++++++++++++ .../grpc-nats-micro/src/method_name_input.rs | 6 ----- .../grpc-nats-micro/src/service_name_input.rs | 6 ----- .../src/service_version_input.rs | 6 ----- .../src/subject_prefix_input.rs | 6 ----- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs index 9cf444044a..2dc73dde70 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/binding/tests.rs @@ -42,3 +42,25 @@ fn carries_the_description_micro_discovery_reports() { 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/method_name_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs index 3587f5d343..14596d930b 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/method_name_input.rs @@ -17,9 +17,3 @@ impl MethodNameInput { &self.0 } } - -impl std::fmt::Display for MethodNameInput { - 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_name_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs index 34c145be06..840f89419a 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_name_input.rs @@ -17,9 +17,3 @@ impl ServiceNameInput { &self.0 } } - -impl std::fmt::Display for ServiceNameInput { - 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_version_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs index 18c3819330..03252f9082 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/service_version_input.rs @@ -18,9 +18,3 @@ impl ServiceVersionInput { &self.0 } } - -impl std::fmt::Display for ServiceVersionInput { - 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/subject_prefix_input.rs b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs index e99a488ed0..92f17973e8 100644 --- a/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs +++ b/rsworkspace/crates/platform/grpc-nats-micro/src/subject_prefix_input.rs @@ -17,9 +17,3 @@ impl SubjectPrefixInput { &self.0 } } - -impl std::fmt::Display for SubjectPrefixInput { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -}