structured_proxy/lib.rs
1//! Universal gRPC→REST transcoding proxy.
2//!
3//! Config-driven: same binary, different YAML = different product proxy.
4//! Works with ANY gRPC service via proto descriptors as config.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! structured-proxy --config sid-proxy.yaml
10//! structured-proxy --config sflow-proxy.yaml
11//! ```
12//!
13//! ## JWT verification
14//!
15//! The bearer-token check sits behind [`hooks::TokenVerifier`]. A build gets one
16//! of two:
17//!
18//! - the **built-in** verifier (keys from `auth.jwt`), whose crypto backend is
19//! picked by a feature: `rust_crypto` (default, pure Rust) or `aws_lc_rs`
20//! (opt-in, constant-time / FIPS-capable, links aws-lc via C FFI). Both may be
21//! compiled in at once — Cargo features are additive, so a dependency graph
22//! with two dependents asking for different backends unifies into exactly that
23//! build. `aws_lc_rs` then wins: it is constant-time and free of the `rsa`
24//! advisory `rust_crypto` carries. [`ProxyServer::from_config`] settles that
25//! choice for the process; a process where another crate may reach
26//! `jsonwebtoken` before any server exists calls
27//! [`install_default_crypto_provider`] from `main` instead.
28//! - an **injected** one, supplied by the embedder through
29//! [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a
30//! whole dependency graph, a backend feature cannot be chosen per binary —
31//! injection is how a consumer that needs a different one gets it without
32//! deciding for everyone else who links this crate. Such a build takes
33//! `default-features = false` and links no JWT crypto at all.
34//!
35//! ## Outbound TLS
36//!
37//! JWKS fetches and the rate-limit service go over rustls. The crypto provider
38//! is the one the process installed with
39//! `rustls::crypto::CryptoProvider::install_default`, else the one the crypto
40//! backend feature brings (aws-lc for `aws_lc_rs`, RustCrypto for
41//! `rust_crypto`). A build with neither feature links no TLS crypto, and a
42//! config that needs an outbound client then fails at startup unless a
43//! provider is installed first.
44
45// `builtin_jwt` is implied by each backend and never meant to stand alone: on
46// its own it would link jsonwebtoken with no provider, which panics at runtime.
47#[cfg(all(
48 feature = "builtin_jwt",
49 not(any(feature = "rust_crypto", feature = "aws_lc_rs"))
50))]
51compile_error!(
52 "feature `builtin_jwt` needs a crypto backend: enable `rust_crypto` or `aws_lc_rs` \
53 (or neither, and inject a verifier with `ProxyServer::with_token_verifier`)"
54);
55
56pub mod auth;
57pub mod config;
58mod cors;
59mod embed;
60mod guard;
61mod held;
62pub mod hooks;
63pub mod oidc;
64pub mod openapi;
65mod serve;
66pub mod service;
67pub mod shield;
68mod tls;
69pub mod transcode;
70pub mod upstream;
71
72/// Settle the process-wide JWT crypto provider. See
73/// [`install_default_crypto_provider`] for when a call is needed.
74#[cfg(feature = "builtin_jwt")]
75pub use auth::crypto::install_default_crypto_provider;
76pub use serve::{serve, serve_with, ServeOptions};
77pub use service::{ConnectionInfo, ProxyService};
78
79use axum::extract::State;
80use axum::http::StatusCode;
81use axum::response::IntoResponse;
82use axum::routing::get;
83use axum::{Json, Router};
84use prost_reflect::DescriptorPool;
85use std::net::SocketAddr;
86use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
87use tower_http::trace::TraceLayer;
88
89use std::sync::Arc;
90
91use config::{DescriptorSource, ProxyConfig, ScopeConfig};
92use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier};
93use upstream::Upstream;
94
95/// What the proxy's handlers share. Every request extracts its own clone, so
96/// it holds the upstream handle and reference-counted settings only.
97#[derive(Clone, Debug)]
98pub(crate) struct ProxyState<U> {
99 /// The gRPC service transcoded calls and readiness probes go to.
100 pub(crate) upstream: U,
101 /// Headers to forward from HTTP to gRPC.
102 pub(crate) forwarded_headers: Arc<[String]>,
103 /// SSE keep-alive interval (seconds) for server-streaming responses.
104 pub(crate) sse_keep_alive_secs: u64,
105}
106
107/// Universal proxy server.
108pub struct ProxyServer {
109 config: ProxyConfig,
110 /// Optional pre-loaded descriptor pool (for embedded mode).
111 descriptor_pool: Option<DescriptorPool>,
112 /// Optional in-process forward-auth/PDP gate (embedded Tier-2 hook).
113 auth_decider: Option<Arc<dyn AuthDecider>>,
114 /// The traffic the injected decider gates; `transcoded` when unset.
115 auth_decider_scope: Option<ScopeConfig>,
116 /// Optional stateless OIDC surface backing (embedded Tier-2 hook).
117 oidc_backend: Option<Arc<dyn OidcBackend>>,
118 /// Embedder-supplied extra stateless routes (embedded Tier-2 hook).
119 extra_routes: Vec<ExtraRoute>,
120 /// Override for the `/verify` forward-auth path of an injected AuthDecider.
121 verify_path: Option<String>,
122 /// Embedder-supplied JWT verifier, replacing the built-in one.
123 token_verifier: Option<Arc<dyn TokenVerifier>>,
124 /// How the transcoded routes render errors and frame NDJSON streams.
125 transcode: transcode::TranscodeOptions,
126}
127
128impl Default for ProxyServer {
129 /// [`ProxyServer::new`]: every capability off.
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135impl ProxyServer {
136 /// Create from YAML config file.
137 pub fn from_config(config: ProxyConfig) -> Self {
138 // Earliest point this crate owns: settle the JWT crypto provider here,
139 // long before the first token arrives. A process whose other crates
140 // reach jsonwebtoken before any server exists calls
141 // `install_default_crypto_provider` from `main` instead.
142 #[cfg(feature = "builtin_jwt")]
143 auth::crypto::install_default_crypto_provider();
144
145 Self {
146 config,
147 descriptor_pool: None,
148 auth_decider: None,
149 auth_decider_scope: None,
150 oidc_backend: None,
151 extra_routes: Vec::new(),
152 verify_path: None,
153 token_verifier: None,
154 transcode: transcode::TranscodeOptions::default(),
155 }
156 }
157
158 /// Create from a YAML document: the [`ProxyConfig`] plus the transcoding
159 /// settings it does not hold (`error_details:`,
160 /// `streaming.ndjson_envelope` and `response_headers:`), applied as
161 /// [`with_error_details`], [`with_ndjson_envelope`] and
162 /// [`with_denied_response_headers`] would. A top-level or `streaming:` key
163 /// no setting reads is logged as a warning.
164 ///
165 /// # Errors
166 ///
167 /// Invalid YAML, a [`ProxyConfig`] that fails
168 /// [`validate`](ProxyConfig::validate), an `error_details` route pattern
169 /// that is relative or not a valid glob, or a `response_headers.deny`
170 /// entry that is not a header name.
171 ///
172 /// [`with_error_details`]: Self::with_error_details
173 /// [`with_ndjson_envelope`]: Self::with_ndjson_envelope
174 /// [`with_denied_response_headers`]: Self::with_denied_response_headers
175 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
176 let config = ProxyConfig::from_yaml_str(yaml)?;
177 for key in config::unknown_config_keys(yaml) {
178 tracing::warn!(%key, "unknown config key is ignored");
179 }
180 let settings: config::TranscodeFileConfig = serde_yaml::from_str(yaml)?;
181 let options = settings
182 .options()
183 .map_err(|e| anyhow::anyhow!("invalid transcoding config: {e}"))?;
184 let mut server = Self::from_config(config);
185 server.transcode = options;
186 Ok(server)
187 }
188
189 /// [`from_yaml_str`](Self::from_yaml_str) on the contents of a file.
190 ///
191 /// # Errors
192 ///
193 /// The file cannot be read, or its contents are rejected by
194 /// [`from_yaml_str`](Self::from_yaml_str).
195 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
196 Self::from_yaml_str(&std::fs::read_to_string(path)?)
197 }
198
199 /// The configuration this server was created with.
200 pub fn config(&self) -> &ProxyConfig {
201 &self.config
202 }
203
204 /// A proxy with every capability off: native gRPC and gRPC-Web pass to
205 /// the upstream, every other request to the fallback (`404` by default).
206 /// Turn on what you need with the methods below; each sets the config
207 /// section of the same name, so code and YAML describe one proxy.
208 ///
209 /// # Examples
210 ///
211 /// ```
212 /// use structured_proxy::config::{ConcurrencyConfig, ScopeConfig, Traffic};
213 /// use structured_proxy::ProxyServer;
214 ///
215 /// # fn build() -> anyhow::Result<()> {
216 /// // Your gRPC API, with at most 1000 calls in flight and nothing else.
217 /// let grpc = tonic::service::Routes::default();
218 /// let service = ProxyServer::new()
219 /// .with_concurrency_limit(ConcurrencyConfig {
220 /// max_in_flight: 1000,
221 /// scope: Some(ScopeConfig::traffic([Traffic::Grpc])),
222 /// })
223 /// .service(grpc)?;
224 /// # let _ = service;
225 /// # Ok(())
226 /// # }
227 /// # build().unwrap();
228 /// ```
229 pub fn new() -> Self {
230 let mut config = ProxyConfig::default();
231 config.health.enabled = false;
232 config.metrics.enabled = false;
233 Self::from_config(config)
234 }
235
236 /// The remote gRPC upstream (`upstream.default`), for
237 /// [`upstream`](Self::upstream) and [`serve`](Self::serve).
238 pub fn with_upstream_address(mut self, address: impl Into<String>) -> Self {
239 self.config.upstream = Some(config::UpstreamConfig {
240 default: address.into(),
241 });
242 self
243 }
244
245 /// The listener of [`serve`](Self::serve): address, TLS, connection
246 /// limit (`listen:`).
247 pub fn with_listen(mut self, listen: config::ListenConfig) -> Self {
248 self.config.listen = listen;
249 self
250 }
251
252 /// Transcode only these services (`package.Service`) and methods
253 /// (`package.Service/Method`) of the descriptors (`transcode.only`); every
254 /// annotated RPC by default. A name the descriptors do not hold fails the
255 /// build.
256 pub fn with_transcoded_rpcs(
257 mut self,
258 names: impl IntoIterator<Item = impl Into<String>>,
259 ) -> Self {
260 self.config.transcode.only = names.into_iter().map(Into::into).collect();
261 self
262 }
263
264 /// Path aliases rewritten before routing (`aliases:`).
265 pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = config::AliasConfig>) -> Self {
266 self.config.aliases = aliases.into_iter().collect();
267 self
268 }
269
270 /// The request headers transcoded calls forward as gRPC metadata
271 /// (`forwarded_headers:`), replacing the default list.
272 pub fn with_forwarded_headers(
273 mut self,
274 names: impl IntoIterator<Item = impl Into<String>>,
275 ) -> Self {
276 self.config.forwarded_headers = names.into_iter().map(Into::into).collect();
277 self
278 }
279
280 /// Health probe endpoints (`health:`).
281 pub fn with_health(mut self, health: config::HealthConfig) -> Self {
282 self.config.health = health;
283 self
284 }
285
286 /// The Prometheus metrics endpoint (`metrics:`).
287 pub fn with_metrics(mut self, metrics: config::MetricsConfig) -> Self {
288 self.config.metrics = metrics;
289 self
290 }
291
292 /// The OpenAPI spec and docs endpoints (`openapi:`).
293 pub fn with_openapi(mut self, openapi: config::OpenApiConfig) -> Self {
294 self.config.openapi = Some(openapi);
295 self
296 }
297
298 /// Static OIDC discovery and JWKS endpoints (`oidc_discovery:`).
299 pub fn with_oidc_discovery(mut self, oidc: config::OidcDiscoveryConfig) -> Self {
300 self.config.oidc_discovery = Some(oidc);
301 self
302 }
303
304 /// The CORS policy (`cors:`).
305 pub fn with_cors(mut self, cors: config::CorsConfig) -> Self {
306 self.config.cors = cors;
307 self
308 }
309
310 /// Translate gRPC-Web to gRPC for an upstream that speaks only gRPC
311 /// (`grpc_web.translate`).
312 pub fn with_grpc_web_translation(mut self, translate: bool) -> Self {
313 self.config.grpc_web.translate = translate;
314 self
315 }
316
317 /// Server-streaming behavior (`streaming:`).
318 pub fn with_streaming(mut self, streaming: config::StreamingConfig) -> Self {
319 self.config.streaming = streaming;
320 self
321 }
322
323 /// Maintenance mode (`maintenance:`).
324 pub fn with_maintenance(mut self, maintenance: config::MaintenanceConfig) -> Self {
325 self.config.maintenance = maintenance;
326 self
327 }
328
329 /// The limit on requests in flight (`concurrency:`).
330 pub fn with_concurrency_limit(mut self, concurrency: config::ConcurrencyConfig) -> Self {
331 self.config.concurrency = Some(concurrency);
332 self
333 }
334
335 /// Rate limits (`shield:`); build the section with [`config::from_yaml`].
336 pub fn with_rate_limits(mut self, shield: config::ShieldConfig) -> Self {
337 self.config.shield = Some(shield);
338 self
339 }
340
341 /// JWT auth, route policies, forward-auth and ext_authz (`auth:`); build
342 /// the section with [`config::from_yaml`].
343 pub fn with_auth(mut self, auth: config::AuthConfig) -> Self {
344 self.config.auth = Some(auth);
345 self
346 }
347
348 /// Create with an embedded descriptor pool (for sid-proxy backward compat).
349 pub fn with_descriptors(mut self, pool: DescriptorPool) -> Self {
350 self.descriptor_pool = Some(pool);
351 self
352 }
353
354 /// Inject an in-process forward-auth / PDP decision (embedded Tier-2 hook).
355 ///
356 /// The decider gates the transcoded requests inline (other traffic with
357 /// [`with_auth_decider_scope`](Self::with_auth_decider_scope)) and also
358 /// backs the `/verify` forward-auth endpoint. Its signature is `axum`-free
359 /// (see [`hooks::AuthDecider`]), so the embedder never names an HTTP
360 /// framework.
361 pub fn with_auth_decider(mut self, decider: Arc<dyn AuthDecider>) -> Self {
362 self.auth_decider = Some(decider);
363 self
364 }
365
366 /// Choose the traffic the injected [`AuthDecider`] gates, `transcoded`
367 /// by default; the `/verify` endpoint is never behind it, since it answers
368 /// for the decider.
369 ///
370 /// # Examples
371 ///
372 /// ```
373 /// use structured_proxy::config::{ScopeConfig, Traffic};
374 /// use structured_proxy::ProxyServer;
375 ///
376 /// # fn build() -> anyhow::Result<()> {
377 /// // Gate native gRPC calls as well as the transcoded ones.
378 /// let server = ProxyServer::from_yaml_str("service:\n name: demo\n")?
379 /// .with_auth_decider_scope(ScopeConfig::traffic([Traffic::Transcoded, Traffic::Grpc]));
380 /// # let _ = server;
381 /// # Ok(())
382 /// # }
383 /// # build().unwrap();
384 /// ```
385 pub fn with_auth_decider_scope(mut self, scope: ScopeConfig) -> Self {
386 self.auth_decider_scope = Some(scope);
387 self
388 }
389
390 /// Back the stateless OIDC surface (discovery, JWKS, userinfo) with the
391 /// embedder's key/client metadata (embedded Tier-2 hook).
392 ///
393 /// When set, this supersedes the config-driven static `oidc_discovery`
394 /// routes. See [`hooks::OidcBackend`].
395 pub fn with_oidc_backend(mut self, backend: Arc<dyn OidcBackend>) -> Self {
396 self.oidc_backend = Some(backend);
397 self
398 }
399
400 /// Register extra stateless routes through an `axum`-free adapter (embedded
401 /// Tier-2 hook). See [`hooks::ExtraRoute`] / [`hooks::ExtraRouteHandler`].
402 pub fn with_extra_routes(mut self, routes: impl IntoIterator<Item = ExtraRoute>) -> Self {
403 self.extra_routes.extend(routes);
404 self
405 }
406
407 /// Set the path at which the injected [`AuthDecider`] answers forward-auth
408 /// sub-requests (`/verify`). Independent of any JWT `forward_auth` config, so
409 /// a decider-only embedder can place it without a JWT block.
410 ///
411 /// Resolution order for the path: this override, then
412 /// `auth.forward_auth.path` from config, then the default `/auth/verify`.
413 pub fn with_verify_path(mut self, path: impl Into<String>) -> Self {
414 self.verify_path = Some(path.into());
415 self
416 }
417
418 /// Verify bearer tokens with the embedder's own verifier instead of the
419 /// built-in one (embedded Tier-2 hook).
420 ///
421 /// The JWT middleware keeps everything around the signature check — route
422 /// policies, the roles claim, claim→header forwarding — and takes the
423 /// verdict from [`hooks::TokenVerifier`]. Use this when the built-in crypto
424 /// backend is not the one this binary needs: a validated / FIPS module, an
425 /// HSM, or a verifier the embedder already owns. It is also the way out of
426 /// Cargo's feature unification, which makes `rust_crypto` / `aws_lc_rs` a
427 /// property of the whole dependency graph rather than of one binary.
428 ///
429 /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt`
430 /// then only configures claim forwarding, and any key source in it is
431 /// ignored (with a warning), since the verifier owns its own keys.
432 pub fn with_token_verifier(mut self, verifier: Arc<dyn TokenVerifier>) -> Self {
433 self.token_verifier = Some(verifier);
434 self
435 }
436
437 /// Choose which transcoded routes return the upstream's
438 /// `google.rpc.Status` details in their error bodies. Every route does by
439 /// default; see [`transcode::error::ErrorDetailsPolicy`] to switch them
440 /// off globally or per route.
441 pub fn with_error_details(mut self, policy: transcode::error::ErrorDetailsPolicy) -> Self {
442 self.transcode = self.transcode.with_error_details(policy);
443 self
444 }
445
446 /// Wrap NDJSON stream lines in `{"result"}` / `{"error"}` envelopes; see
447 /// [`transcode::TranscodeOptions::with_ndjson_envelope`].
448 pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
449 self.transcode = self.transcode.with_ndjson_envelope(enabled);
450 self
451 }
452
453 /// Keep these upstream response metadata keys off the HTTP responses of
454 /// the transcoded routes; see
455 /// [`transcode::TranscodeOptions::with_denied_response_headers`].
456 pub fn with_denied_response_headers(
457 mut self,
458 names: impl IntoIterator<Item = http::HeaderName>,
459 ) -> Self {
460 self.transcode = self.transcode.with_denied_response_headers(names);
461 self
462 }
463
464 /// Load descriptor pool from configured sources.
465 ///
466 /// Multiple descriptor files are merged into a single pool,
467 /// enabling multi-service proxying from one binary.
468 fn load_descriptors(&self) -> anyhow::Result<DescriptorPool> {
469 if let Some(pool) = &self.descriptor_pool {
470 return Ok(pool.clone());
471 }
472
473 let mut pool = DescriptorPool::new();
474
475 for source in &self.config.descriptors {
476 match source {
477 DescriptorSource::File { file } => {
478 let bytes = std::fs::read(file).map_err(|e| {
479 anyhow::anyhow!("Failed to read descriptor file {:?}: {}", file, e)
480 })?;
481 pool.decode_file_descriptor_set(bytes.as_slice())
482 .map_err(|e| {
483 anyhow::anyhow!("Failed to decode descriptor file {:?}: {}", file, e)
484 })?;
485 tracing::info!("Loaded descriptor from {:?}", file);
486 }
487 DescriptorSource::Reflection { reflection } => {
488 tracing::warn!(
489 "gRPC reflection client not supported — use descriptor files instead (reflection endpoint: {})",
490 reflection
491 );
492 }
493 DescriptorSource::Embedded { bytes } => {
494 pool.decode_file_descriptor_set(*bytes).map_err(|e| {
495 anyhow::anyhow!("Failed to decode embedded descriptors: {}", e)
496 })?;
497 }
498 }
499 }
500
501 Ok(pool)
502 }
503
504 /// The path an injected [`AuthDecider`] answers `/verify` at: the
505 /// `with_verify_path` override, then `auth.forward_auth.path`, then the
506 /// default `/auth/verify`. Only meaningful when a decider is set (the
507 /// override does not apply to config-driven JWT forward-auth).
508 fn decider_verify_path(&self) -> String {
509 self.verify_path.clone().unwrap_or_else(|| {
510 self.config
511 .auth
512 .as_ref()
513 .and_then(|a| a.forward_auth.as_ref())
514 .map(|fa| fa.path.clone())
515 .unwrap_or_else(|| "/auth/verify".to_string())
516 })
517 }
518
519 /// The verify path that is ACTUALLY mounted, or `None` when no verify route
520 /// is mounted. This is what the collision guard and maintenance-exempt list
521 /// must use, since the two mount sites use different paths:
522 /// - an injected decider mounts at [`decider_verify_path`](Self::decider_verify_path)
523 /// (the `with_verify_path` override applies), whereas
524 /// - config-driven JWT forward-auth mounts `forward_auth.routes()` at
525 /// `auth.forward_auth.path` (the override does NOT apply, and it mounts
526 /// only when `auth.mode == "jwt"`, since the endpoint shares the built JWT
527 /// `Auth`).
528 fn mounted_verify_path(&self) -> Option<String> {
529 if self.auth_decider.is_some() {
530 return Some(self.decider_verify_path());
531 }
532 self.config.auth.as_ref().and_then(|a| {
533 if a.mode != "jwt" {
534 return None;
535 }
536 a.forward_auth
537 .as_ref()
538 .filter(|fa| fa.enabled)
539 .map(|fa| fa.path.clone())
540 })
541 }
542
543 /// Every `(method, path)` route mounted before the verify endpoint, used to
544 /// reject a real collision with a clear error instead of an axum
545 /// duplicate-route panic. `method` is the uppercase HTTP token; same-path
546 /// routes with different methods do NOT collide (the extra-route adapter and
547 /// axum merge them), so the key is the pair, not the path alone.
548 ///
549 /// Must stay exhaustive: health probes, metrics, OpenAPI spec/docs, the OIDC
550 /// surface (injected backend or config-driven static discovery), embedder
551 /// extra routes, and the transcoded REST routes. All built-in surfaces here
552 /// are `GET`.
553 fn reserved_routes(
554 &self,
555 pool: &DescriptorPool,
556 selection: &transcode::RpcSelection,
557 ) -> anyhow::Result<Vec<(String, String)>> {
558 let mut routes = Vec::new();
559 let mut get = |path: String| routes.push(("GET".to_string(), path));
560 if self.config.health.enabled {
561 get(self.config.health.path.clone());
562 get(self.config.health.live_path.clone());
563 get(self.config.health.ready_path.clone());
564 get(self.config.health.startup_path.clone());
565 }
566 if self.config.metrics.enabled {
567 get(self.config.metrics.path.clone());
568 }
569 if let Some(openapi) = self.config.openapi.as_ref().filter(|o| o.enabled) {
570 get(openapi.path.clone());
571 get(openapi.docs_path.clone());
572 }
573 // OIDC: an injected backend supersedes config-driven static discovery.
574 if let Some(backend) = &self.oidc_backend {
575 for doc in backend.metadata_documents() {
576 get(doc.path);
577 }
578 get(backend.jwks().path);
579 get(backend.userinfo_path());
580 } else if let Some(cfg) = &self.config.oidc_discovery {
581 if let Some(oidc) = oidc::Oidc::build(cfg)
582 .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
583 {
584 for path in oidc.paths() {
585 get(path);
586 }
587 }
588 }
589 for route in &self.extra_routes {
590 routes.push((route.method.as_str().to_string(), route.path.clone()));
591 }
592 routes.extend(transcode::route_paths(
593 pool,
594 &self.config.aliases,
595 selection,
596 ));
597 Ok(routes)
598 }
599
600 /// A lazy channel to the configured upstream address (`upstream.default`),
601 /// the upstream of the standalone proxy. It connects on first use, giving
602 /// up after five seconds.
603 ///
604 /// # Errors
605 ///
606 /// No upstream address is configured, or it is not a valid URI.
607 pub fn upstream(&self) -> anyhow::Result<tonic::transport::Channel> {
608 let Some(upstream) = &self.config.upstream else {
609 anyhow::bail!("no gRPC upstream address is configured (upstream.default)");
610 };
611 Ok(
612 tonic::transport::Channel::from_shared(upstream.default.clone())
613 .map_err(|e| anyhow::anyhow!("invalid gRPC upstream URL: {e}"))?
614 .connect_timeout(std::time::Duration::from_secs(5))
615 .connect_lazy(),
616 )
617 }
618
619 /// The proxy's HTTP routes in front of the configured upstream address
620 /// (see [`upstream`](Self::upstream)), as an axum `Router` to serve or to
621 /// merge into an axum application. It answers HTTP only; use
622 /// [`service`](Self::service) for native gRPC on the same listener, or for
623 /// an upstream in process.
624 ///
625 /// # Errors
626 ///
627 /// No valid upstream address, or a configuration [`service`](Self::service)
628 /// rejects.
629 pub fn router(&self) -> anyhow::Result<Router> {
630 let (router, _, _) = self.routes(self.upstream()?)?;
631 Ok(router)
632 }
633
634 /// The whole proxy as one tower service in front of `upstream`: native
635 /// gRPC requests reach `upstream` unchanged, every other request the
636 /// proxy's routes, whose transcoded calls go to `upstream` too. See
637 /// [`ProxyService`].
638 ///
639 /// `upstream` is any gRPC service ([`upstream::Upstream`]): the embedder's
640 /// own tonic services in process, with no socket between them and the
641 /// proxy, or a remote [`Channel`](tonic::transport::Channel) such as
642 /// [`upstream`](Self::upstream).
643 ///
644 /// # Errors
645 ///
646 /// An invalid configuration (see [`ProxyConfig::validate`]), descriptors
647 /// that cannot be loaded, a route mounted twice, or a malformed auth,
648 /// authz, shield or OIDC section.
649 ///
650 /// # Examples
651 ///
652 /// ```
653 /// use structured_proxy::ProxyServer;
654 ///
655 /// # fn build() -> anyhow::Result<()> {
656 /// let grpc = tonic::service::Routes::default(); // add your services here
657 /// let service = ProxyServer::from_yaml_str("service:\n name: demo\n")?.service(grpc)?;
658 /// # let _ = service;
659 /// # Ok(())
660 /// # }
661 /// # build().unwrap();
662 /// ```
663 pub fn service<U: Upstream>(&self, upstream: U) -> anyhow::Result<ProxyService<U>> {
664 let (routes, cors, guards) = self.routes(upstream.clone())?;
665 // The routes answer a browser's preflight for gRPC-Web too, so its
666 // call carries the same policy unless the upstream sets its own.
667 let grpc_web_cors = self.config.cors.grpc_web.then_some(cors);
668 Ok(ProxyService::new(
669 upstream,
670 routes,
671 grpc_web_cors,
672 guards,
673 self.config.grpc_web.translate,
674 ))
675 }
676
677 /// Build the axum router with all endpoints, calling `upstream`, the CORS
678 /// policy it answers under, and the guards, for the traffic outside it.
679 fn routes<U: Upstream>(
680 &self,
681 upstream: U,
682 ) -> anyhow::Result<(Router, CorsLayer, Arc<guard::Guards>)> {
683 // Enforce cross-field invariants on the embedded path too, where the
684 // config is built directly instead of through `from_yaml_str`.
685 self.config.validate()?;
686 let pool = self.load_descriptors()?;
687 let selection = transcode::RpcSelection::new(&pool, &self.config.transcode.only)
688 .map_err(|e| anyhow::anyhow!("invalid transcode.only: {e}"))?;
689
690 let service_name = self.config.service.name.clone();
691
692 // The verify path that is actually mounted (branch-correct), if any.
693 let verify_path = self.mounted_verify_path();
694
695 // Validate the WHOLE mounted edge BEFORE any router is built, so a
696 // malformed path (missing leading '/') or a collision (between built-in
697 // routes, the OIDC surface, embedder extra routes, transcoded paths, or
698 // the verify endpoint) is a clear error instead of an axum panic at
699 // `.route`/`.merge`. Collisions are keyed by (method, path): same-path
700 // routes with different methods are legal (they merge), so only a
701 // repeated (method, path) — or any overlap with the verify endpoint,
702 // which answers ALL methods (`*`) — is a real conflict.
703 let mut mounted = self.reserved_routes(&pool, &selection)?;
704 if let Some(vp) = &verify_path {
705 mounted.push(("*".to_string(), vp.clone()));
706 }
707 // Key by NORMALIZED shape, not raw text: axum/matchit treats two dynamic
708 // routes with the same structure but different param names (e.g.
709 // `/v1/x/{a}` and `/v1/x/{b}`) as a conflict, so they must collide here.
710 let mut methods_by_shape: std::collections::HashMap<
711 String,
712 std::collections::HashSet<&str>,
713 > = std::collections::HashMap::new();
714 for (method, path) in &mounted {
715 if !path.starts_with('/') {
716 anyhow::bail!("route path {path:?} must start with '/'");
717 }
718 let methods = methods_by_shape
719 .entry(normalize_route_shape(path))
720 .or_default();
721 // `*` (the verify endpoint) claims every method, so it conflicts with
722 // any other route on the same shape, and vice versa.
723 let conflict = if method == "*" {
724 !methods.is_empty()
725 } else {
726 methods.contains("*") || methods.contains(method.as_str())
727 };
728 if conflict {
729 anyhow::bail!("route path {path:?} is registered by more than one endpoint");
730 }
731 methods.insert(method.as_str());
732 }
733
734 // Keep the actually-configured probe / metrics / verify paths reachable
735 // under maintenance mode. The default exempt list names the default
736 // paths; once those are relocated via config, the relocated paths must
737 // be exempted too, or maintenance would 503 probe and forward-auth
738 // traffic that was intentionally exempt before.
739 let mut maintenance_exempt = self.config.maintenance.exempt_paths.clone();
740 if self.config.health.enabled {
741 maintenance_exempt.push(self.config.health.path.clone());
742 maintenance_exempt.push(self.config.health.live_path.clone());
743 maintenance_exempt.push(self.config.health.ready_path.clone());
744 maintenance_exempt.push(self.config.health.startup_path.clone());
745 }
746 if self.config.metrics.enabled {
747 maintenance_exempt.push(self.config.metrics.path.clone());
748 }
749 if let Some(vp) = &verify_path {
750 maintenance_exempt.push(vp.clone());
751 }
752
753 // A forwarded name gRPC metadata cannot carry would be rejected by a
754 // conforming upstream on every request; refuse it here instead.
755 if let Some(name) = self
756 .config
757 .forwarded_headers
758 .iter()
759 .find(|name| !transcode::metadata::is_grpc_key(name))
760 {
761 anyhow::bail!(
762 "forwarded_headers entry {name:?} is not a gRPC metadata key \
763 (letters, digits, '_', '-' and '.')"
764 );
765 }
766
767 let state = ProxyState {
768 upstream,
769 forwarded_headers: self.config.forwarded_headers.as_slice().into(),
770 sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs,
771 };
772
773 let cors = self.build_cors()?;
774
775 // Build transcoding routes from descriptor pool.
776 let transcode_routes = transcode::routes_with_options(
777 &pool,
778 &self.config.aliases,
779 &self.transcode.clone().with_selection(selection.clone()),
780 );
781
782 // JWT auth, if configured (auth.mode == "jwt").
783 let auth = match &self.config.auth {
784 Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone())
785 .map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?,
786 None => None,
787 };
788 // Forward-auth verification endpoint, sharing the built Auth.
789 let forward_auth = auth.as_ref().and_then(|built| {
790 auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone())
791 });
792 let guards = self.guards(auth, maintenance_exempt, &mounted)?;
793
794 // Health routes. Paths are configurable; the whole group is skippable.
795 let health_routes = if self.config.health.enabled {
796 let health = &self.config.health;
797 let health_service_name = service_name.clone();
798 Router::new()
799 .route(
800 &health.path,
801 get({
802 let name = health_service_name.clone();
803 move || async move {
804 Json(serde_json::json!({
805 "status": "ok",
806 "service": name,
807 }))
808 }
809 }),
810 )
811 .route(&health.live_path, get(|| async { StatusCode::OK }))
812 .route(
813 &health.ready_path,
814 get(|State(state): State<ProxyState<U>>| async move {
815 let mut client =
816 tonic_health::pb::health_client::HealthClient::new(state.upstream);
817 let check = client.check(tonic_health::pb::HealthCheckRequest {
818 service: String::new(),
819 });
820 // An upstream that does not answer in time is not ready.
821 match tokio::time::timeout(transcode::UPSTREAM_DEADLINE, check)
822 .await
823 .unwrap_or_else(|_| {
824 Err(tonic::Status::deadline_exceeded("health check timed out"))
825 }) {
826 Ok(resp) => {
827 let status = resp.into_inner().status;
828 if status
829 == tonic_health::pb::health_check_response::ServingStatus::Serving
830 as i32
831 {
832 StatusCode::OK
833 } else {
834 StatusCode::SERVICE_UNAVAILABLE
835 }
836 }
837 Err(_) => StatusCode::SERVICE_UNAVAILABLE,
838 }
839 }),
840 )
841 .route(&health.startup_path, get(|| async { StatusCode::OK }))
842 } else {
843 Router::new()
844 };
845
846 // Metrics route. Path is configurable; the endpoint is skippable.
847 let metrics_routes = if self.config.metrics.enabled {
848 Router::new().route(
849 &self.config.metrics.path,
850 get(|| async {
851 let encoder = prometheus::TextEncoder::new();
852 let metric_families = prometheus::default_registry().gather();
853 match encoder.encode_to_string(&metric_families) {
854 Ok(text) => (
855 StatusCode::OK,
856 [(
857 axum::http::header::CONTENT_TYPE,
858 "text/plain; version=0.0.4; charset=utf-8",
859 )],
860 text,
861 )
862 .into_response(),
863 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
864 }
865 }),
866 )
867 } else {
868 Router::new()
869 };
870
871 // OpenAPI + docs routes (if enabled).
872 let openapi_routes = self.build_openapi_routes(&pool, &selection);
873
874 // OIDC routes (public, like the health endpoints). An injected
875 // OidcBackend supersedes the config-driven static discovery: the proxy
876 // hosts the HTTP surface, the embedder supplies the content.
877 let oidc_routes = match &self.oidc_backend {
878 Some(backend) => embed::oidc_backend_routes(backend.clone()),
879 None => match &self.config.oidc_discovery {
880 Some(cfg) => oidc::Oidc::build(cfg)
881 .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
882 .map(|o| o.routes())
883 .unwrap_or_default(),
884 None => Router::new(),
885 },
886 };
887
888 let endpoints = Router::new()
889 .merge(health_routes)
890 .merge(metrics_routes)
891 .merge(openapi_routes)
892 .merge(oidc_routes)
893 .merge(embed::extra_routes_router(&self.extra_routes));
894
895 // Forward-auth `/verify` endpoint. An injected AuthDecider owns it when
896 // present (in-process PDP); otherwise the config-driven JWT ForwardAuth
897 // backs it. Its path was validated above.
898 let verify = if let Some(decider) = &self.auth_decider {
899 let decider = decider.clone();
900 Router::new().route(
901 &self.decider_verify_path(),
902 axum::routing::any(move |req: axum::extract::Request| {
903 let decider = decider.clone();
904 async move { embed::verify_via_decider(decider, req).await }
905 }),
906 )
907 } else if let Some(forward_auth) = &forward_auth {
908 forward_auth.routes()
909 } else {
910 Router::new()
911 };
912
913 // Each class of traffic behind the guards that cover it, so a request
914 // runs only the guards of its own class. A path no route answers
915 // reaches the plain 404 (or the fallback's own guards).
916 let router = Router::new()
917 .merge(guards.router(transcode_routes, guard::Class::Transcoded))
918 .merge(guards.router(endpoints, guard::Class::Endpoints))
919 .merge(guards.router(verify, guard::Class::Verify))
920 .layer(TraceLayer::new_for_http());
921 // Outermost: wraps every enforcement layer so short-circuited
922 // responses keep CORS headers, and answers preflight before auth.
923 let router = cors::layer(router, cors.clone()).with_state(state);
924
925 Ok((router, cors, Arc::new(guards)))
926 }
927
928 /// The guards the configuration and the hooks turn on, each with its
929 /// scope; `maintenance_exempt` lists the paths maintenance mode leaves
930 /// reachable, `mounted` the `(method, path)` of every route.
931 ///
932 /// # Errors
933 ///
934 /// A malformed shield, authz or concurrency section, or a scope that
935 /// covers no traffic, names an invalid path glob or an invalid method.
936 fn guards(
937 &self,
938 auth: Option<Arc<auth::Auth>>,
939 maintenance_exempt: Vec<String>,
940 mounted: &[(String, String)],
941 ) -> anyhow::Result<guard::Guards> {
942 use config::Traffic::{Endpoints, Grpc, Transcoded};
943 // `*` marks a route that answers every method, not a method.
944 let routed: Vec<http::Method> = mounted
945 .iter()
946 .filter(|(method, _)| method != "*")
947 .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok())
948 .collect();
949 let scope = |config: Option<&ScopeConfig>, default: &[config::Traffic], what: &str| {
950 guard::Scope::compile(config, default, what, &routed).map_err(anyhow::Error::msg)
951 };
952 let mut guards = guard::Guards::default();
953 // Mounted only while maintenance is on, so normal traffic pays nothing
954 // for it.
955 let maintenance = &self.config.maintenance;
956 if maintenance.enabled {
957 guards.maintenance = Some((
958 Arc::new(guard::Maintenance {
959 exempt: maintenance_exempt,
960 message: maintenance.message.clone(),
961 }),
962 scope(
963 maintenance.scope.as_ref(),
964 &[Transcoded, Endpoints],
965 "maintenance",
966 )?,
967 ));
968 }
969 if let Some(cfg) = &self.config.concurrency {
970 guards.concurrency = Some((
971 guard::Concurrency::build(cfg).map_err(anyhow::Error::msg)?,
972 // The proxy's own endpoints stay out by default: a health
973 // probe refused under load gets a busy instance restarted.
974 scope(cfg.scope.as_ref(), &[Transcoded, Grpc], "concurrency")?,
975 ));
976 }
977 if let Some(cfg) = &self.config.shield {
978 if let Some(shield) = shield::Shield::build(cfg)
979 .map_err(|e| anyhow::anyhow!("invalid shield config: {e}"))?
980 {
981 guards.shield = Some((
982 shield,
983 scope(cfg.scope.as_ref(), &[Transcoded, Endpoints], "shield")?,
984 ));
985 }
986 }
987 let auth_config = self.config.auth.as_ref();
988 if let Some(auth) = auth {
989 guards.auth = Some((
990 auth,
991 scope(
992 auth_config.and_then(|a| a.scope.as_ref()),
993 &[Transcoded, Endpoints],
994 "auth",
995 )?,
996 ));
997 }
998 if let Some(cfg) = auth_config.and_then(|a| a.authz.as_ref()) {
999 if let Some(authz) = auth::authz::Authz::build(cfg)
1000 .map_err(|e| anyhow::anyhow!("invalid authz config: {e}"))?
1001 {
1002 guards.authz = Some((
1003 authz,
1004 scope(cfg.scope.as_ref(), &[Transcoded], "auth.authz")?,
1005 ));
1006 }
1007 }
1008 if let Some(decider) = &self.auth_decider {
1009 guards.decider = Some((
1010 decider.clone(),
1011 scope(
1012 self.auth_decider_scope.as_ref(),
1013 &[Transcoded],
1014 "auth_decider",
1015 )?,
1016 ));
1017 }
1018 Ok(guards)
1019 }
1020
1021 fn build_openapi_routes<S>(
1022 &self,
1023 pool: &DescriptorPool,
1024 selection: &transcode::RpcSelection,
1025 ) -> Router<S>
1026 where
1027 S: Clone + Send + Sync + 'static,
1028 {
1029 let openapi_config = match &self.config.openapi {
1030 Some(cfg) if cfg.enabled => cfg,
1031 _ => return Router::new(),
1032 };
1033
1034 let spec = openapi::generate(pool, openapi_config, &self.config.aliases, selection);
1035 let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
1036 let openapi_path = openapi_config.path.clone();
1037 let docs_path = openapi_config.docs_path.clone();
1038 let title = openapi_config
1039 .title
1040 .clone()
1041 .unwrap_or_else(|| self.config.service.name.clone());
1042 let openapi_path_for_docs = openapi_path.clone();
1043
1044 tracing::info!("OpenAPI spec at {}, docs at {}", openapi_path, docs_path,);
1045
1046 Router::new()
1047 .route(
1048 &openapi_path,
1049 get(move || async move {
1050 (
1051 StatusCode::OK,
1052 [(
1053 axum::http::header::CONTENT_TYPE,
1054 "application/json; charset=utf-8",
1055 )],
1056 spec_json,
1057 )
1058 }),
1059 )
1060 .route(
1061 &docs_path,
1062 get(move || async move {
1063 let html = openapi::docs_html(&openapi_path_for_docs, &title);
1064 (
1065 StatusCode::OK,
1066 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
1067 html,
1068 )
1069 }),
1070 )
1071 }
1072
1073 /// The CORS policy of `cors:`.
1074 ///
1075 /// # Errors
1076 ///
1077 /// An origin that is not a header value, or an `expose_headers` entry that
1078 /// is not a header name: dropping either would quietly narrow the policy.
1079 fn build_cors(&self) -> anyhow::Result<CorsLayer> {
1080 let config = &self.config.cors;
1081 // Checked in both modes, so a typo fails the same way whether or not
1082 // origins are set.
1083 let configured = config
1084 .expose_headers
1085 .iter()
1086 .map(|name| {
1087 http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
1088 anyhow::anyhow!("cors.expose_headers entry {name:?} is not a header name")
1089 })
1090 })
1091 .collect::<anyhow::Result<Vec<_>>>()?;
1092 let layer = if config.origins.is_empty() {
1093 tracing::warn!("CORS origins not set — using permissive CORS (dev mode)");
1094 // Exposes every header already.
1095 CorsLayer::permissive()
1096 } else {
1097 let origins = config
1098 .origins
1099 .iter()
1100 .map(|origin| {
1101 http::HeaderValue::from_str(origin)
1102 .map_err(|_| anyhow::anyhow!("cors.origins entry {origin:?} is not valid"))
1103 })
1104 .collect::<anyhow::Result<Vec<_>>>()?;
1105 let exposed = [
1106 // What a gRPC-Web client reads its status and details from.
1107 http::HeaderName::from_static("grpc-status"),
1108 http::HeaderName::from_static("grpc-message"),
1109 http::HeaderName::from_static("grpc-status-details-bin"),
1110 // Let browser clients read the rate-limit budget and back off.
1111 http::HeaderName::from_static("ratelimit-limit"),
1112 http::HeaderName::from_static("ratelimit-remaining"),
1113 http::HeaderName::from_static("ratelimit-reset"),
1114 http::HeaderName::from_static("retry-after"),
1115 ]
1116 .into_iter()
1117 .chain(configured);
1118 // With credentials the Fetch standard (§3.2.5) forbids `*` for
1119 // methods and headers, so the preflight's own request is echoed
1120 // back instead: what the browser asked for, from an allowed origin.
1121 CorsLayer::new()
1122 .allow_origin(AllowOrigin::list(origins))
1123 .allow_methods(AllowMethods::mirror_request())
1124 .allow_headers(AllowHeaders::mirror_request())
1125 .allow_credentials(true)
1126 .expose_headers(exposed.collect::<Vec<_>>())
1127 };
1128 Ok(match config.max_age_secs {
1129 Some(secs) => layer.max_age(std::time::Duration::from_secs(secs)),
1130 None => layer,
1131 })
1132 }
1133
1134 /// The [`ServeOptions`] of `listen:`: TLS from `listen.tls` (mTLS with its
1135 /// `client_ca_file`), the `listen.max_connections` cap and the connection
1136 /// timeouts, for [`serve_with`] on a listener of your own.
1137 ///
1138 /// # Errors
1139 ///
1140 /// A `max_connections`, `header_read_timeout_secs` or
1141 /// `tls.handshake_timeout_secs` of zero, TLS files that cannot be loaded,
1142 /// or no rustls crypto provider for TLS.
1143 pub fn serve_options(&self) -> anyhow::Result<ServeOptions> {
1144 use std::time::Duration;
1145 let listen = &self.config.listen;
1146 anyhow::ensure!(
1147 listen.header_read_timeout_secs > 0,
1148 "listen.header_read_timeout_secs must be at least 1"
1149 );
1150 let mut options = ServeOptions::new()
1151 .idle_timeout(
1152 (listen.idle_timeout_secs > 0)
1153 .then(|| Duration::from_secs(listen.idle_timeout_secs)),
1154 )
1155 .header_read_timeout(Duration::from_secs(listen.header_read_timeout_secs));
1156 if let Some(max) = listen.max_connections {
1157 anyhow::ensure!(max > 0, "listen.max_connections must be at least 1");
1158 options = options.max_connections(max);
1159 }
1160 if let Some(tls) = &listen.tls {
1161 anyhow::ensure!(
1162 tls.handshake_timeout_secs > 0,
1163 "listen.tls.handshake_timeout_secs must be at least 1"
1164 );
1165 options = options
1166 .tls(
1167 tls::server_config(tls)
1168 .map_err(|e| anyhow::anyhow!("invalid listen.tls: {e}"))?,
1169 )
1170 .tls_handshake_timeout(Duration::from_secs(tls.handshake_timeout_secs));
1171 }
1172 Ok(options)
1173 }
1174
1175 /// Serve the proxy on the configured listen address in front of the
1176 /// configured upstream address: REST and native gRPC on one port, with
1177 /// the TLS and connection limit of `listen:` (see
1178 /// [`serve_options`](Self::serve_options) and [`serve_with`]).
1179 ///
1180 /// # Errors
1181 ///
1182 /// What [`upstream`](Self::upstream), [`service`](Self::service) and
1183 /// [`serve_options`](Self::serve_options) reject, an invalid listen
1184 /// address, or a listener that fails.
1185 pub async fn serve(&self) -> anyhow::Result<()> {
1186 let service = self.service(self.upstream()?)?;
1187 let options = self.serve_options()?;
1188 let addr: SocketAddr = self.config.listen.http.parse()?;
1189 let listener = tokio::net::TcpListener::bind(addr).await?;
1190
1191 tracing::info!(
1192 tls = self.config.listen.tls.is_some(),
1193 "{} listening on {}",
1194 self.config.service.name,
1195 addr
1196 );
1197 serve_with(listener, service, options).await?;
1198 Ok(())
1199 }
1200}
1201
1202/// Canonical shape of an axum route path for collision detection: every dynamic
1203/// segment (`{name}` capture or `{*name}` wildcard) is replaced by a
1204/// name-independent placeholder, so structurally identical routes that differ
1205/// only in parameter name (which axum/matchit rejects as a conflict) map to the
1206/// same key. Literal segments are unchanged.
1207fn normalize_route_shape(path: &str) -> String {
1208 path.split('/')
1209 .map(|seg| {
1210 if seg.starts_with("{*") && seg.ends_with('}') {
1211 "{*}"
1212 } else if seg.starts_with('{') && seg.ends_with('}') {
1213 "{}"
1214 } else {
1215 seg
1216 }
1217 })
1218 .collect::<Vec<_>>()
1219 .join("/")
1220}
1221
1222/// A [`ProxyState`] for tests whose routers never call the upstream: a lazy
1223/// channel to a port nothing listens on.
1224#[cfg(test)]
1225pub(crate) fn test_state() -> ProxyState<tonic::transport::Channel> {
1226 ProxyState {
1227 upstream: tonic::transport::Channel::from_static("http://127.0.0.1:1")
1228 .connect_timeout(std::time::Duration::from_millis(100))
1229 .connect_lazy(),
1230 forwarded_headers: Arc::from([]),
1231 sse_keep_alive_secs: 15,
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests;