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. Rather than wrestling with the generic
20// `tonic::transport::Router<S>` type parameter (which is unnameable), we
21// store closures that apply a service to a `Server` builder and accumulate
22// them into a `tonic::transport::Router` incrementally.
23//
24// Each service closure returns an `ErasedRouter` — a boxed object that can
25// 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 and returns
29/// 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`] so that all
116    /// gRPC error responses carry structured JSON details. If
117    /// [`with_reflection`](Self::with_reflection) was called, the reflection
118    /// 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
233    /// `tonic::transport::Router` can't be accumulated type-safely without the
234    /// concrete service type). For multiple services, compose them before calling
235    /// `add_service`, or use the raw tonic API.
236    pub fn build(self) -> GrpcServer {
237        let serve_fns = Arc::new(self.serve_fns);
238
239        let start_fn = Arc::new(move |addr: SocketAddr, cancel: CancellationToken| {
240            let fns = serve_fns.clone();
241            tokio::spawn(async move {
242                // Clone before calling cancelled_owned() so the original is still
243                // usable in the else branch.
244                let signal = Box::pin(cancel.clone().cancelled_owned());
245
246                // Use the last added service (simplest safe approach).
247                if let Some(serve_fn) = fns.last() {
248                    tracing::info!(addr = %addr, "gRPC server listening");
249                    match serve_fn(addr, signal).await {
250                        Ok(()) => tracing::info!(addr = %addr, "gRPC server stopped"),
251                        Err(e) => tracing::error!(addr = %addr, error = %e, "gRPC server error"),
252                    }
253                } else {
254                    tracing::warn!(addr = %addr, "gRPC server has no services — waiting for cancel");
255                    cancel.cancelled().await;
256                }
257            })
258        });
259
260        GrpcServer::new(self.name, self.config, start_fn)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use std::convert::Infallible;
267    use std::future::{Ready, ready};
268    use std::task::{Context, Poll};
269
270    use http::{Request, Response};
271    use rskit_bootstrap::Component;
272    use rskit_errors::ErrorCode;
273    use tonic::body::Body;
274    use tower::Service;
275
276    use super::*;
277
278    #[tokio::test]
279    async fn builder_preserves_name_and_waits_for_cancel_without_services() {
280        let config = GrpcServerConfig::new("127.0.0.1", 50051);
281        let server = GrpcServerBuilder::new(config)
282            .with_name("api-grpc")
283            .with_reflection(b"not-a-descriptor")
284            .build();
285
286        assert_eq!(server.name(), "api-grpc");
287        assert!(!server.health().is_healthy());
288
289        server.start().await.expect("start no-service server");
290        assert!(server.health().is_healthy());
291        server.stop().await.expect("stop no-service server");
292        assert!(!server.health().is_healthy());
293    }
294
295    #[tokio::test]
296    async fn start_rejects_invalid_bind_address_before_spawning() {
297        let config = GrpcServerConfig::new("not a host", 50051);
298        let server = GrpcServerBuilder::new(config).build();
299
300        let error = server.start().await.expect_err("invalid address");
301
302        assert_eq!(error.code(), ErrorCode::Internal);
303        assert!(error.message().contains("invalid gRPC address"));
304        assert!(!server.health().is_healthy());
305    }
306
307    #[tokio::test]
308    async fn builder_runs_added_service_on_local_listener_until_cancelled() {
309        let config = GrpcServerConfig::new("127.0.0.1", 0);
310        let server = GrpcServerBuilder::new(config)
311            .with_name("local-grpc")
312            .add_service(EmptyGrpcService)
313            .build();
314
315        server.start().await.expect("start service server");
316        assert!(server.health().is_healthy());
317        tokio::time::sleep(Duration::from_millis(20)).await;
318        server.stop().await.expect("stop service server");
319        tokio::time::sleep(Duration::from_millis(20)).await;
320    }
321
322    #[tokio::test]
323    async fn builder_falls_back_when_reflection_descriptor_is_invalid() {
324        let config = GrpcServerConfig::new("127.0.0.1", 0);
325        let server = GrpcServerBuilder::new(config)
326            .with_reflection(b"not a protobuf file descriptor set")
327            .add_service(EmptyGrpcService)
328            .build();
329
330        server.start().await.expect("start service server");
331        tokio::time::sleep(Duration::from_millis(20)).await;
332        server.stop().await.expect("stop service server");
333        tokio::time::sleep(Duration::from_millis(20)).await;
334    }
335
336    #[tokio::test]
337    async fn builder_reports_missing_tls_files_from_service_task_without_panicking() {
338        let config = GrpcServerConfig {
339            host: "127.0.0.1".to_string(),
340            port: 0,
341            tls: Some(crate::config::TlsConfig {
342                cert_path: "missing-cert.pem".to_string(),
343                key_path: "missing-key.pem".to_string(),
344            }),
345            ..GrpcServerConfig::default()
346        };
347        let server = GrpcServerBuilder::new(config)
348            .add_service(EmptyGrpcService)
349            .build();
350
351        server.start().await.expect("spawn service task");
352        tokio::time::sleep(Duration::from_millis(20)).await;
353        server.stop().await.expect("stop service server");
354    }
355
356    #[derive(Clone)]
357    struct EmptyGrpcService;
358
359    impl tonic::server::NamedService for EmptyGrpcService {
360        const NAME: &'static str = "test.Empty";
361    }
362
363    impl tonic::codegen::Service<Request<Body>> for EmptyGrpcService {
364        type Response = Response<Body>;
365        type Error = Infallible;
366        type Future = Ready<Result<Self::Response, Self::Error>>;
367
368        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
369            Poll::Ready(Ok(()))
370        }
371
372        fn call(&mut self, _req: Request<Body>) -> Self::Future {
373            ready(Ok(Response::new(Body::default())))
374        }
375    }
376
377    #[test]
378    fn empty_test_service_is_ready_and_returns_empty_body() {
379        let mut service = EmptyGrpcService;
380        let waker = std::task::Waker::noop();
381        let mut cx = Context::from_waker(waker);
382
383        assert!(matches!(service.poll_ready(&mut cx), Poll::Ready(Ok(()))));
384        let response = service
385            .call(Request::new(Body::default()))
386            .into_inner()
387            .unwrap();
388        assert_eq!(response.status(), http::StatusCode::OK);
389    }
390}