Skip to main content

omp_rpc/
hello.rs

1//! Gateway schema and capability negotiation.
2
3use omp_core::{Str, str::IntoStr};
4use omp_proto::gateway::v1::{
5	HelloRequest, HelloResponse, gateway_client::GatewayClient, gateway_server::Gateway,
6};
7use tonic::{Request, Response, Status, transport::Channel};
8
9use crate::Error;
10
11/// Oldest protocol schema understood by this gateway implementation.
12pub const MIN_SCHEMA_REV: u32 = 1;
13
14/// Gateway Hello endpoint advertising this server's protocol surface.
15#[derive(Clone, Debug)]
16pub struct HelloService {
17	server_version: Str,
18	capabilities:   Vec<Str>,
19}
20
21impl HelloService {
22	/// Create a Hello endpoint with a server version and advertised
23	/// capabilities.
24	pub fn new(server_version: impl IntoStr, capabilities: Vec<Str>) -> Self {
25		Self { server_version: server_version.into_str(), capabilities }
26	}
27}
28
29#[tonic::async_trait]
30impl Gateway for HelloService {
31	async fn hello(
32		&self,
33		_request: Request<HelloRequest>,
34	) -> Result<Response<HelloResponse>, Status> {
35		Ok(Response::new(HelloResponse {
36			schema_rev:     omp_proto::SCHEMA_REV,
37			min_schema_rev: MIN_SCHEMA_REV,
38			capabilities:   self.capabilities.iter().map(ToString::to_string).collect(),
39			server_version: self.server_version.to_string(),
40		}))
41	}
42}
43
44/// Protocol information negotiated with a remote gateway.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct Peer {
47	/// Schema revision implemented by the server.
48	pub schema_rev:     u32,
49	/// Capabilities advertised by the server.
50	pub capabilities:   Vec<Str>,
51	/// Human-readable server build version.
52	pub server_version: Str,
53}
54
55impl Peer {
56	/// Return whether the server advertised `cap`.
57	pub fn has(&self, cap: &str) -> bool {
58		self
59			.capabilities
60			.iter()
61			.any(|candidate| candidate.as_str() == cap)
62	}
63}
64
65/// Perform the mandatory Hello handshake and reject incompatible schema
66/// revisions.
67pub async fn handshake(
68	channel: Channel,
69	client: &str,
70	capabilities: &[&str],
71) -> Result<Peer, Error> {
72	handshake_at(channel, client, capabilities, omp_proto::SCHEMA_REV).await
73}
74
75async fn handshake_at(
76	channel: Channel,
77	client: &str,
78	capabilities: &[&str],
79	client_rev: u32,
80) -> Result<Peer, Error> {
81	let request = HelloRequest {
82		client:       client.to_owned(),
83		schema_rev:   client_rev,
84		capabilities: capabilities.iter().map(|cap| (*cap).to_owned()).collect(),
85	};
86	let response = GatewayClient::new(channel)
87		.hello(request)
88		.await?
89		.into_inner();
90
91	if response.schema_rev < client_rev {
92		return Err(Error::SchemaTooOld { server: response.schema_rev, client: client_rev });
93	}
94	if omp_proto::SCHEMA_REV < response.min_schema_rev {
95		return Err(Error::SchemaUnsupported {
96			server_min: response.min_schema_rev,
97			client:     omp_proto::SCHEMA_REV,
98		});
99	}
100
101	Ok(Peer {
102		schema_rev:     response.schema_rev,
103		capabilities:   response
104			.capabilities
105			.into_iter()
106			.map(IntoStr::into_str)
107			.collect(),
108		server_version: response.server_version.into_str(),
109	})
110}
111
112#[cfg(test)]
113mod tests {
114	use std::path::Path;
115
116	use omp_core::IntoStr;
117	use omp_proto::gateway::v1::gateway_server::GatewayServer;
118	use tempfile::TempDir;
119	use tonic::transport::Server;
120	use tonic_health::pb::{HealthCheckRequest, health_check_response::ServingStatus};
121
122	use super::*;
123	use crate::{health_service, uds};
124
125	async fn serve(path: &Path) {
126		let incoming = uds::listen(path).await.expect("test UDS should bind");
127		let (reporter, health) = health_service();
128		reporter.set_serving::<GatewayServer<HelloService>>().await;
129		let hello =
130			HelloService::new("test-server", vec!["inference.turn".into_str(), "blob.v1".into_str()]);
131		tokio::spawn(async move {
132			Server::builder()
133				.add_service(health)
134				.add_service(GatewayServer::new(hello))
135				.serve_with_incoming(incoming)
136				.await
137				.expect("test server should run");
138		});
139	}
140
141	fn socket(tempdir: &TempDir) -> std::path::PathBuf {
142		tempdir.path().join("rpc.sock")
143	}
144
145	#[tokio::test]
146	async fn handshake_reports_server_capabilities() {
147		let tempdir = tempfile::tempdir().expect("temporary directory should be created");
148		let socket = socket(&tempdir);
149		serve(&socket).await;
150		let channel = uds::connect(&socket).await.expect("client should connect");
151		let peer = handshake(channel, "test-client", &["inference.turn"])
152			.await
153			.expect("matching revisions should negotiate");
154
155		assert_eq!(peer.schema_rev, omp_proto::SCHEMA_REV);
156		assert_eq!(peer.server_version.as_str(), "test-server");
157		assert!(peer.has("inference.turn"));
158		assert!(peer.has("blob.v1"));
159		assert!(!peer.has("search"));
160	}
161
162	#[tokio::test]
163	async fn rejects_server_older_than_client() {
164		let tempdir = tempfile::tempdir().expect("temporary directory should be created");
165		let socket = socket(&tempdir);
166		serve(&socket).await;
167		let channel = uds::connect(&socket).await.expect("client should connect");
168		let client_rev = omp_proto::SCHEMA_REV + 1;
169		let error = handshake_at(channel, "new-client", &[], client_rev)
170			.await
171			.expect_err("newer client must reject this server");
172
173		assert!(matches!(
174			error,
175			Error::SchemaTooOld { server, client }
176				if server == omp_proto::SCHEMA_REV && client == client_rev
177		));
178	}
179
180	#[tokio::test]
181	async fn standard_health_check_is_serving() {
182		let tempdir = tempfile::tempdir().expect("temporary directory should be created");
183		let socket = socket(&tempdir);
184		serve(&socket).await;
185		let channel = uds::connect(&socket).await.expect("client should connect");
186		let response = tonic_health::pb::health_client::HealthClient::new(channel)
187			.check(HealthCheckRequest { service: String::new() })
188			.await
189			.expect("health check should succeed")
190			.into_inner();
191
192		assert_eq!(response.status, ServingStatus::Serving as i32);
193	}
194}