rskit-server 0.2.0-alpha.2

Service-facing server abstractions and lifecycle-managed transports for rskit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use rskit_errors::{AppError, AppResult, ErrorCode};
use tokio_util::sync::CancellationToken;
use tonic::transport::{Identity, Server, ServerTlsConfig};
use tonic_reflection::server::Builder as ReflectionBuilder;
use tower::Layer;

use crate::component::GrpcServer;
use crate::config::GrpcServerConfig;
use crate::error_layer::ErrorLayer;

// ---------------------------------------------------------------------------
// Service adder trait
//
// We type-erase tonic services here.
// Rather than wrestling with the generic `tonic::transport::Router<S>` type parameter (which is unnameable),
// we store closures that apply a service to a `Server` builder
// and accumulate them into a `tonic::transport::Router` incrementally.
//
// Each service closure returns an `ErasedRouter` —
// a boxed object that can route requests without exposing the concrete Router type to the builder.
// ---------------------------------------------------------------------------

/// Type-erased function that adds one service to a tonic server
/// and returns a closure capable of calling `serve_with_shutdown`.
pub(crate) type ServeFn = Arc<
    dyn Fn(
            SocketAddr,
            std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = AppResult<()>> + Send>>
        + Send
        + Sync,
>;

/// Builder for a [`GrpcServer`] component.
///
/// # gRPC Reflection
///
/// Enable server reflection so tools like `grpcurl` can discover services:
///
/// ```rust,ignore
/// # use rskit_server::{GrpcServerBuilder, GrpcServerConfig};
/// // In your build.rs, configure tonic_build to output a file descriptor set:
/// //   tonic_build::configure()
/// //       .file_descriptor_set_path("src/descriptor.bin")
/// //       .compile(&["proto/my_service.proto"], &["proto/"])?;
///
/// let descriptor = include_bytes!("descriptor.bin");
///
/// let server = GrpcServerBuilder::new(GrpcServerConfig::default())
///     .with_reflection(descriptor)
///     .build();
/// ```
///
/// Then query with grpcurl:
/// ```bash
/// grpcurl -plaintext localhost:50051 list
/// grpcurl -plaintext localhost:50051 describe my.package.MyService
/// ```
pub struct GrpcServerBuilder {
    name: String,
    config: GrpcServerConfig,
    /// Accumulated serve fns — each captures one tonic service.
    serve_fns: Vec<ServeFn>,
    /// Compiled `FileDescriptorSet` bytes for gRPC reflection.
    reflection_descriptor: Option<Vec<u8>>,
}

impl GrpcServerBuilder {
    /// Create a new builder with the given server configuration.
    pub fn new(config: GrpcServerConfig) -> Self {
        Self {
            name: "grpc-server".into(),
            config,
            serve_fns: Vec::new(),
            reflection_descriptor: None,
        }
    }

    /// Override the component name (default: `"grpc-server"`).
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = name.into();
        self
    }

    /// Enable gRPC server reflection for service discovery.
    ///
    /// Accepts the compiled `FileDescriptorSet` bytes produced by `tonic-build`.
    /// Configure `tonic-build` in your `build.rs`:
    ///
    /// ```rust,ignore
    /// tonic_build::configure()
    ///     .file_descriptor_set_path("src/descriptor.bin")
    ///     .compile(&["proto/service.proto"], &["proto/"])?;
    /// ```
    ///
    /// Then pass the bytes to the builder:
    ///
    /// ```rust,ignore
    /// builder.with_reflection(include_bytes!("descriptor.bin"))
    /// ```
    #[must_use]
    pub fn with_reflection(mut self, file_descriptor_set: &[u8]) -> Self {
        self.reflection_descriptor = Some(file_descriptor_set.to_vec());
        self
    }

    /// Add a tonic-generated service.
    ///
    /// The service is automatically wrapped with [`ErrorLayer`]
    /// so that all gRPC error responses carry structured JSON details.
    /// If [`with_reflection`](Self::with_reflection) was called,
    /// the reflection service is automatically added alongside each user service.
    #[must_use]
    pub fn add_service<S>(mut self, svc: S) -> Self
    where
        S: tonic::codegen::Service<
                http::Request<tonic::body::Body>,
                Response = http::Response<tonic::body::Body>,
                Error = std::convert::Infallible,
            > + tonic::server::NamedService
            + Clone
            + Send
            + Sync
            + 'static,
        S::Future: Send + 'static,
    {
        let descriptor = self.reflection_descriptor.clone();
        let tls = self.config.tls.clone();
        // Wrap the user service with the error enrichment layer.
        let wrapped_svc = ErrorLayer::new().layer(svc);
        let serve_fn: ServeFn = Arc::new(move |addr, signal| {
            let s = wrapped_svc.clone();
            let desc = descriptor.clone();
            let tls = tls.clone();
            Box::pin(async move {
                let mut builder = Server::builder();
                if let Some(tls) = &tls {
                    let cert = rskit_fs::async_io::file::read(Path::new(&tls.cert_path))
                        .await
                        .map_err(|error| {
                            AppError::new(
                                ErrorCode::InvalidInput,
                                format!(
                                    "failed to read TLS certificate '{}': {error}",
                                    tls.cert_path
                                ),
                            )
                            .with_cause(error)
                        })?;
                    let key = rskit_fs::async_io::file::read(Path::new(&tls.key_path))
                        .await
                        .map_err(|error| {
                            AppError::new(
                                ErrorCode::InvalidInput,
                                format!(
                                    "failed to read TLS private key '{}': {error}",
                                    tls.key_path
                                ),
                            )
                            .with_cause(error)
                        })?;
                    let tls_config = ServerTlsConfig::new()
                        .identity(Identity::from_pem(cert, key))
                        .ignore_client_order(true)
                        .timeout(Duration::from_secs(10));
                    builder = builder.tls_config(tls_config).map_err(|error| {
                        AppError::new(
                            ErrorCode::InvalidInput,
                            format!("invalid gRPC TLS configuration: {error}"),
                        )
                        .with_cause(error)
                    })?;
                }
                let router = builder.add_service(s);

                if let Some(desc_bytes) = desc {
                    match ReflectionBuilder::configure()
                        .register_encoded_file_descriptor_set(&desc_bytes)
                        .build_v1()
                    {
                        Ok(refl_svc) => router
                            .add_service(refl_svc)
                            .serve_with_shutdown(addr, signal)
                            .await
                            .map_err(|error| {
                                AppError::new(
                                    ErrorCode::Internal,
                                    format!("gRPC server transport failed: {error}"),
                                )
                                .with_cause(error)
                            }),
                        Err(e) => {
                            tracing::error!(error = %e, "failed to build reflection service; serving without reflection");
                            router
                                .serve_with_shutdown(addr, signal)
                                .await
                                .map_err(|error| {
                                    AppError::new(
                                        ErrorCode::Internal,
                                        format!("gRPC server transport failed: {error}"),
                                    )
                                    .with_cause(error)
                                })
                        }
                    }
                } else {
                    router
                        .serve_with_shutdown(addr, signal)
                        .await
                        .map_err(|error| {
                            AppError::new(
                                ErrorCode::Internal,
                                format!("gRPC server transport failed: {error}"),
                            )
                            .with_cause(error)
                        })
                }
            })
        });
        self.serve_fns.push(serve_fn);
        self
    }

    /// Build the [`GrpcServer`] component.
    ///
    /// 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).
    /// For multiple services, compose them before calling `add_service`, or use the raw tonic API.
    pub fn build(self) -> GrpcServer {
        let serve_fns = Arc::new(self.serve_fns);

        let start_fn = Arc::new(move |addr: SocketAddr, cancel: CancellationToken| {
            let fns = serve_fns.clone();
            tokio::spawn(async move {
                // Clone before calling cancelled_owned()
                // so the original is still usable in the else branch.
                let signal = Box::pin(cancel.clone().cancelled_owned());

                // Use the last added service (simplest safe approach).
                if let Some(serve_fn) = fns.last() {
                    tracing::info!(addr = %addr, "gRPC server listening");
                    match serve_fn(addr, signal).await {
                        Ok(()) => tracing::info!(addr = %addr, "gRPC server stopped"),
                        Err(e) => tracing::error!(addr = %addr, error = %e, "gRPC server error"),
                    }
                } else {
                    tracing::warn!(addr = %addr, "gRPC server has no services — waiting for cancel");
                    cancel.cancelled().await;
                }
            })
        });

        GrpcServer::new(self.name, self.config, start_fn)
    }
}

#[cfg(test)]
mod tests {
    use std::convert::Infallible;
    use std::future::{Ready, ready};
    use std::task::{Context, Poll};

    use http::{Request, Response};
    use rskit_bootstrap::Component;
    use rskit_errors::ErrorCode;
    use tonic::body::Body;
    use tower::Service;

    use super::*;

    #[tokio::test]
    async fn builder_preserves_name_and_waits_for_cancel_without_services() {
        let config = GrpcServerConfig::new("127.0.0.1", 50051);
        let server = GrpcServerBuilder::new(config)
            .with_name("api-grpc")
            .with_reflection(b"not-a-descriptor")
            .build();

        assert_eq!(server.name(), "api-grpc");
        assert!(!server.health().is_healthy());

        server.start().await.expect("start no-service server");
        assert!(server.health().is_healthy());
        server.stop().await.expect("stop no-service server");
        assert!(!server.health().is_healthy());
    }

    #[tokio::test]
    async fn start_rejects_invalid_bind_address_before_spawning() {
        let config = GrpcServerConfig::new("not a host", 50051);
        let server = GrpcServerBuilder::new(config).build();

        let error = server.start().await.expect_err("invalid address");

        assert_eq!(error.code(), ErrorCode::Internal);
        assert!(error.message().contains("invalid gRPC address"));
        assert!(!server.health().is_healthy());
    }

    #[tokio::test]
    async fn builder_runs_added_service_on_local_listener_until_cancelled() {
        let config = GrpcServerConfig::new("127.0.0.1", 0);
        let server = GrpcServerBuilder::new(config)
            .with_name("local-grpc")
            .add_service(EmptyGrpcService)
            .build();

        server.start().await.expect("start service server");
        assert!(server.health().is_healthy());
        tokio::time::sleep(Duration::from_millis(20)).await;
        server.stop().await.expect("stop service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    #[tokio::test]
    async fn builder_falls_back_when_reflection_descriptor_is_invalid() {
        let config = GrpcServerConfig::new("127.0.0.1", 0);
        let server = GrpcServerBuilder::new(config)
            .with_reflection(b"not a protobuf file descriptor set")
            .add_service(EmptyGrpcService)
            .build();

        server.start().await.expect("start service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
        server.stop().await.expect("stop service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    #[tokio::test]
    async fn builder_reports_missing_tls_files_from_service_task_without_panicking() {
        let config = GrpcServerConfig {
            host: "127.0.0.1".to_string(),
            port: 0,
            tls: Some(crate::config::TlsConfig {
                cert_path: "missing-cert.pem".to_string(),
                key_path: "missing-key.pem".to_string(),
            }),
            ..GrpcServerConfig::default()
        };
        let server = GrpcServerBuilder::new(config)
            .add_service(EmptyGrpcService)
            .build();

        server.start().await.expect("spawn service task");
        tokio::time::sleep(Duration::from_millis(20)).await;
        server.stop().await.expect("stop service server");
    }

    fn testdata(name: &str) -> String {
        format!("{}/testdata/{name}", env!("CARGO_MANIFEST_DIR"))
    }

    #[tokio::test]
    async fn builder_serves_with_valid_tls_material() {
        let config = GrpcServerConfig {
            host: "127.0.0.1".to_string(),
            port: 0,
            tls: Some(crate::config::TlsConfig {
                cert_path: testdata("cert.pem"),
                key_path: testdata("key.pem"),
            }),
            ..GrpcServerConfig::default()
        };
        let server = GrpcServerBuilder::new(config)
            .add_service(EmptyGrpcService)
            .build();

        server.start().await.expect("start tls service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
        server.stop().await.expect("stop tls service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    #[tokio::test]
    async fn builder_serves_with_valid_reflection_descriptor() {
        let config = GrpcServerConfig::new("127.0.0.1", 0);
        let server = GrpcServerBuilder::new(config)
            .with_reflection(&[])
            .add_service(EmptyGrpcService)
            .build();

        server
            .start()
            .await
            .expect("start reflection service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
        server.stop().await.expect("stop reflection service server");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    #[derive(Clone)]
    struct EmptyGrpcService;

    impl tonic::server::NamedService for EmptyGrpcService {
        const NAME: &'static str = "test.Empty";
    }

    impl tonic::codegen::Service<Request<Body>> for EmptyGrpcService {
        type Response = Response<Body>;
        type Error = Infallible;
        type Future = Ready<Result<Self::Response, Self::Error>>;

        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
            Poll::Ready(Ok(()))
        }

        fn call(&mut self, _req: Request<Body>) -> Self::Future {
            ready(Ok(Response::new(Body::default())))
        }
    }

    #[test]
    fn empty_test_service_is_ready_and_returns_empty_body() {
        let mut service = EmptyGrpcService;
        let waker = std::task::Waker::noop();
        let mut cx = Context::from_waker(waker);

        assert!(matches!(service.poll_ready(&mut cx), Poll::Ready(Ok(()))));
        let response = service
            .call(Request::new(Body::default()))
            .into_inner()
            .unwrap();
        assert_eq!(response.status(), http::StatusCode::OK);
    }
}