Skip to main content

rskit_server/
builder.rs

1use std::net::SocketAddr;
2use std::path::Path;
3use std::sync::Arc;
4use std::time::Duration;
5
6use rskit_errors::{AppError, AppResult, ErrorCode};
7use tokio_util::sync::CancellationToken;
8use tonic::transport::{Identity, Server, ServerTlsConfig};
9use tonic_reflection::server::Builder as ReflectionBuilder;
10use tower::Layer;
11
12use crate::component::GrpcServer;
13use crate::config::GrpcServerConfig;
14use crate::error_layer::ErrorLayer;
15
16// ---------------------------------------------------------------------------
17// Service adder trait
18//
19// We type-erase tonic services here.
20// Rather than wrestling with the generic `tonic::transport::Router<S>` type parameter (which is unnameable),
21// we store closures that apply a service to a `Server` builder
22// and accumulate them into a `tonic::transport::Router` incrementally.
23//
24// Each service closure returns an `ErasedRouter` —
25// a boxed object that can route requests without exposing the concrete Router type to the builder.
26// ---------------------------------------------------------------------------
27
28/// Type-erased function that adds one service to a tonic server
29/// and returns a closure capable of calling `serve_with_shutdown`.
30pub(crate) type ServeFn = Arc<
31    dyn Fn(
32            SocketAddr,
33            std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
34        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = AppResult<()>> + Send>>
35        + Send
36        + Sync,
37>;
38
39/// Builder for a [`GrpcServer`] component.
40///
41/// # gRPC Reflection
42///
43/// Enable server reflection so tools like `grpcurl` can discover services:
44///
45/// ```rust,ignore
46/// # use rskit_server::{GrpcServerBuilder, GrpcServerConfig};
47/// // In your build.rs, configure tonic_build to output a file descriptor set:
48/// //   tonic_build::configure()
49/// //       .file_descriptor_set_path("src/descriptor.bin")
50/// //       .compile(&["proto/my_service.proto"], &["proto/"])?;
51///
52/// let descriptor = include_bytes!("descriptor.bin");
53///
54/// let server = GrpcServerBuilder::new(GrpcServerConfig::default())
55///     .with_reflection(descriptor)
56///     .build();
57/// ```
58///
59/// Then query with grpcurl:
60/// ```bash
61/// grpcurl -plaintext localhost:50051 list
62/// grpcurl -plaintext localhost:50051 describe my.package.MyService
63/// ```
64pub struct GrpcServerBuilder {
65    name: String,
66    config: GrpcServerConfig,
67    /// Accumulated serve fns — each captures one tonic service.
68    serve_fns: Vec<ServeFn>,
69    /// Compiled `FileDescriptorSet` bytes for gRPC reflection.
70    reflection_descriptor: Option<Vec<u8>>,
71}
72
73impl GrpcServerBuilder {
74    /// Create a new builder with the given server configuration.
75    pub fn new(config: GrpcServerConfig) -> Self {
76        Self {
77            name: "grpc-server".into(),
78            config,
79            serve_fns: Vec::new(),
80            reflection_descriptor: None,
81        }
82    }
83
84    /// Override the component name (default: `"grpc-server"`).
85    #[must_use]
86    pub fn with_name(mut self, name: impl Into<String>) -> Self {
87        self.name = name.into();
88        self
89    }
90
91    /// Enable gRPC server reflection for service discovery.
92    ///
93    /// Accepts the compiled `FileDescriptorSet` bytes produced by `tonic-build`.
94    /// Configure `tonic-build` in your `build.rs`:
95    ///
96    /// ```rust,ignore
97    /// tonic_build::configure()
98    ///     .file_descriptor_set_path("src/descriptor.bin")
99    ///     .compile(&["proto/service.proto"], &["proto/"])?;
100    /// ```
101    ///
102    /// Then pass the bytes to the builder:
103    ///
104    /// ```rust,ignore
105    /// builder.with_reflection(include_bytes!("descriptor.bin"))
106    /// ```
107    #[must_use]
108    pub fn with_reflection(mut self, file_descriptor_set: &[u8]) -> Self {
109        self.reflection_descriptor = Some(file_descriptor_set.to_vec());
110        self
111    }
112
113    /// Add a tonic-generated service.
114    ///
115    /// The service is automatically wrapped with [`ErrorLayer`]
116    /// so that all gRPC error responses carry structured JSON details.
117    /// If [`with_reflection`](Self::with_reflection) was called,
118    /// the reflection service is automatically added alongside each user service.
119    #[must_use]
120    pub fn add_service<S>(mut self, svc: S) -> Self
121    where
122        S: tonic::codegen::Service<
123                http::Request<tonic::body::Body>,
124                Response = http::Response<tonic::body::Body>,
125                Error = std::convert::Infallible,
126            > + tonic::server::NamedService
127            + Clone
128            + Send
129            + Sync
130            + 'static,
131        S::Future: Send + 'static,
132    {
133        let descriptor = self.reflection_descriptor.clone();
134        let tls = self.config.tls.clone();
135        // Wrap the user service with the error enrichment layer.
136        let wrapped_svc = ErrorLayer::new().layer(svc);
137        let serve_fn: ServeFn = Arc::new(move |addr, signal| {
138            let s = wrapped_svc.clone();
139            let desc = descriptor.clone();
140            let tls = tls.clone();
141            Box::pin(async move {
142                let mut builder = Server::builder();
143                if let Some(tls) = &tls {
144                    let cert = rskit_fs::async_io::file::read(Path::new(&tls.cert_path))
145                        .await
146                        .map_err(|error| {
147                            AppError::new(
148                                ErrorCode::InvalidInput,
149                                format!(
150                                    "failed to read TLS certificate '{}': {error}",
151                                    tls.cert_path
152                                ),
153                            )
154                            .with_cause(error)
155                        })?;
156                    let key = rskit_fs::async_io::file::read(Path::new(&tls.key_path))
157                        .await
158                        .map_err(|error| {
159                            AppError::new(
160                                ErrorCode::InvalidInput,
161                                format!(
162                                    "failed to read TLS private key '{}': {error}",
163                                    tls.key_path
164                                ),
165                            )
166                            .with_cause(error)
167                        })?;
168                    let tls_config = ServerTlsConfig::new()
169                        .identity(Identity::from_pem(cert, key))
170                        .ignore_client_order(true)
171                        .timeout(Duration::from_secs(10));
172                    builder = builder.tls_config(tls_config).map_err(|error| {
173                        AppError::new(
174                            ErrorCode::InvalidInput,
175                            format!("invalid gRPC TLS configuration: {error}"),
176                        )
177                        .with_cause(error)
178                    })?;
179                }
180                let router = builder.add_service(s);
181
182                if let Some(desc_bytes) = desc {
183                    match ReflectionBuilder::configure()
184                        .register_encoded_file_descriptor_set(&desc_bytes)
185                        .build_v1()
186                    {
187                        Ok(refl_svc) => router
188                            .add_service(refl_svc)
189                            .serve_with_shutdown(addr, signal)
190                            .await
191                            .map_err(|error| {
192                                AppError::new(
193                                    ErrorCode::Internal,
194                                    format!("gRPC server transport failed: {error}"),
195                                )
196                                .with_cause(error)
197                            }),
198                        Err(e) => {
199                            tracing::error!(error = %e, "failed to build reflection service; serving without reflection");
200                            router
201                                .serve_with_shutdown(addr, signal)
202                                .await
203                                .map_err(|error| {
204                                    AppError::new(
205                                        ErrorCode::Internal,
206                                        format!("gRPC server transport failed: {error}"),
207                                    )
208                                    .with_cause(error)
209                                })
210                        }
211                    }
212                } else {
213                    router
214                        .serve_with_shutdown(addr, signal)
215                        .await
216                        .map_err(|error| {
217                            AppError::new(
218                                ErrorCode::Internal,
219                                format!("gRPC server transport failed: {error}"),
220                            )
221                            .with_cause(error)
222                        })
223                }
224            })
225        });
226        self.serve_fns.push(serve_fn);
227        self
228    }
229
230    /// Build the [`GrpcServer`] component.
231    ///
232    /// Only the **last** registered service is used for the actual server (since `tonic::transport::Router` can't be accumulated type-safely without the concrete service type).
233    /// For multiple services, compose them before calling `add_service`, or use the raw tonic API.
234    pub fn build(self) -> GrpcServer {
235        let serve_fns = Arc::new(self.serve_fns);
236
237        let start_fn = Arc::new(move |addr: SocketAddr, cancel: CancellationToken| {
238            let fns = serve_fns.clone();
239            tokio::spawn(async move {
240                // Clone before calling cancelled_owned()
241                // so the original is still usable in the else branch.
242                let signal = Box::pin(cancel.clone().cancelled_owned());
243
244                // Use the last added service (simplest safe approach).
245                if let Some(serve_fn) = fns.last() {
246                    tracing::info!(addr = %addr, "gRPC server listening");
247                    match serve_fn(addr, signal).await {
248                        Ok(()) => tracing::info!(addr = %addr, "gRPC server stopped"),
249                        Err(e) => tracing::error!(addr = %addr, error = %e, "gRPC server error"),
250                    }
251                } else {
252                    tracing::warn!(addr = %addr, "gRPC server has no services — waiting for cancel");
253                    cancel.cancelled().await;
254                }
255            })
256        });
257
258        GrpcServer::new(self.name, self.config, start_fn)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use std::convert::Infallible;
265    use std::future::{Ready, ready};
266    use std::task::{Context, Poll};
267
268    use http::{Request, Response};
269    use rskit_bootstrap::Component;
270    use rskit_errors::ErrorCode;
271    use tonic::body::Body;
272    use tower::Service;
273
274    use super::*;
275
276    #[tokio::test]
277    async fn builder_preserves_name_and_waits_for_cancel_without_services() {
278        let config = GrpcServerConfig::new("127.0.0.1", 50051);
279        let server = GrpcServerBuilder::new(config)
280            .with_name("api-grpc")
281            .with_reflection(b"not-a-descriptor")
282            .build();
283
284        assert_eq!(server.name(), "api-grpc");
285        assert!(!server.health().is_healthy());
286
287        server.start().await.expect("start no-service server");
288        assert!(server.health().is_healthy());
289        server.stop().await.expect("stop no-service server");
290        assert!(!server.health().is_healthy());
291    }
292
293    #[tokio::test]
294    async fn start_rejects_invalid_bind_address_before_spawning() {
295        let config = GrpcServerConfig::new("not a host", 50051);
296        let server = GrpcServerBuilder::new(config).build();
297
298        let error = server.start().await.expect_err("invalid address");
299
300        assert_eq!(error.code(), ErrorCode::Internal);
301        assert!(error.message().contains("invalid gRPC address"));
302        assert!(!server.health().is_healthy());
303    }
304
305    #[tokio::test]
306    async fn builder_runs_added_service_on_local_listener_until_cancelled() {
307        let config = GrpcServerConfig::new("127.0.0.1", 0);
308        let server = GrpcServerBuilder::new(config)
309            .with_name("local-grpc")
310            .add_service(EmptyGrpcService)
311            .build();
312
313        server.start().await.expect("start service server");
314        assert!(server.health().is_healthy());
315        tokio::time::sleep(Duration::from_millis(20)).await;
316        server.stop().await.expect("stop service server");
317        tokio::time::sleep(Duration::from_millis(20)).await;
318    }
319
320    #[tokio::test]
321    async fn builder_falls_back_when_reflection_descriptor_is_invalid() {
322        let config = GrpcServerConfig::new("127.0.0.1", 0);
323        let server = GrpcServerBuilder::new(config)
324            .with_reflection(b"not a protobuf file descriptor set")
325            .add_service(EmptyGrpcService)
326            .build();
327
328        server.start().await.expect("start service server");
329        tokio::time::sleep(Duration::from_millis(20)).await;
330        server.stop().await.expect("stop service server");
331        tokio::time::sleep(Duration::from_millis(20)).await;
332    }
333
334    #[tokio::test]
335    async fn builder_reports_missing_tls_files_from_service_task_without_panicking() {
336        let config = GrpcServerConfig {
337            host: "127.0.0.1".to_string(),
338            port: 0,
339            tls: Some(crate::config::TlsConfig {
340                cert_path: "missing-cert.pem".to_string(),
341                key_path: "missing-key.pem".to_string(),
342            }),
343            ..GrpcServerConfig::default()
344        };
345        let server = GrpcServerBuilder::new(config)
346            .add_service(EmptyGrpcService)
347            .build();
348
349        server.start().await.expect("spawn service task");
350        tokio::time::sleep(Duration::from_millis(20)).await;
351        server.stop().await.expect("stop service server");
352    }
353
354    fn testdata(name: &str) -> String {
355        format!("{}/testdata/{name}", env!("CARGO_MANIFEST_DIR"))
356    }
357
358    #[tokio::test]
359    async fn builder_serves_with_valid_tls_material() {
360        let config = GrpcServerConfig {
361            host: "127.0.0.1".to_string(),
362            port: 0,
363            tls: Some(crate::config::TlsConfig {
364                cert_path: testdata("cert.pem"),
365                key_path: testdata("key.pem"),
366            }),
367            ..GrpcServerConfig::default()
368        };
369        let server = GrpcServerBuilder::new(config)
370            .add_service(EmptyGrpcService)
371            .build();
372
373        server.start().await.expect("start tls service server");
374        tokio::time::sleep(Duration::from_millis(20)).await;
375        server.stop().await.expect("stop tls service server");
376        tokio::time::sleep(Duration::from_millis(20)).await;
377    }
378
379    #[tokio::test]
380    async fn builder_serves_with_valid_reflection_descriptor() {
381        let config = GrpcServerConfig::new("127.0.0.1", 0);
382        let server = GrpcServerBuilder::new(config)
383            .with_reflection(&[])
384            .add_service(EmptyGrpcService)
385            .build();
386
387        server
388            .start()
389            .await
390            .expect("start reflection service server");
391        tokio::time::sleep(Duration::from_millis(20)).await;
392        server.stop().await.expect("stop reflection service server");
393        tokio::time::sleep(Duration::from_millis(20)).await;
394    }
395
396    #[derive(Clone)]
397    struct EmptyGrpcService;
398
399    impl tonic::server::NamedService for EmptyGrpcService {
400        const NAME: &'static str = "test.Empty";
401    }
402
403    impl tonic::codegen::Service<Request<Body>> for EmptyGrpcService {
404        type Response = Response<Body>;
405        type Error = Infallible;
406        type Future = Ready<Result<Self::Response, Self::Error>>;
407
408        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
409            Poll::Ready(Ok(()))
410        }
411
412        fn call(&mut self, _req: Request<Body>) -> Self::Future {
413            ready(Ok(Response::new(Body::default())))
414        }
415    }
416
417    #[test]
418    fn empty_test_service_is_ready_and_returns_empty_body() {
419        let mut service = EmptyGrpcService;
420        let waker = std::task::Waker::noop();
421        let mut cx = Context::from_waker(waker);
422
423        assert!(matches!(service.poll_ready(&mut cx), Poll::Ready(Ok(()))));
424        let response = service
425            .call(Request::new(Body::default()))
426            .into_inner()
427            .unwrap();
428        assert_eq!(response.status(), http::StatusCode::OK);
429    }
430}