1use 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
11pub const MIN_SCHEMA_REV: u32 = 1;
13
14#[derive(Clone, Debug)]
16pub struct HelloService {
17 server_version: Str,
18 capabilities: Vec<Str>,
19}
20
21impl HelloService {
22 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#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct Peer {
47 pub schema_rev: u32,
49 pub capabilities: Vec<Str>,
51 pub server_version: Str,
53}
54
55impl Peer {
56 pub fn has(&self, cap: &str) -> bool {
58 self
59 .capabilities
60 .iter()
61 .any(|candidate| candidate.as_str() == cap)
62 }
63}
64
65pub 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}