1use std::future::{Future, IntoFuture};
4use std::net::{IpAddr, SocketAddr};
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use axum::extract::DefaultBodyLimit;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use axum::routing::get;
12use axum::{Router, serve};
13use tokio::net::TcpListener;
14use tower_http::LatencyUnit;
15use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
16use tracing::{Level, error, info, instrument, warn};
17
18use crate::env;
19use crate::environment::Environment;
20
21use super::error::WebServerError;
22use super::shutdown::{AppShutdown, DEFAULT_DRAIN_TIMEOUT, Shutdown};
23use super::state::{StateParts, WebServerState};
24
25pub const ENV_HOST: &str = "WSB_HOST";
27
28pub const ENV_PORT: &str = "WSB_PORT";
30
31pub const DEFAULT_PORT: u16 = 8080;
33
34pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
39
40pub const API_PREFIX: &str = "/api/v1";
46
47pub struct WebServer<S = ()> {
54 host: String,
55 port: u16,
56 environment: Environment,
57 app: S,
58
59 body_limit: usize,
60 root_dir: PathBuf,
61 router: Router<WebServerState<S>>,
62
63 #[cfg(feature = "pages")]
64 frontend: Option<super::frontend::FrontendParams<S>>,
65
66 #[cfg(feature = "pages")]
69 feed: Option<crate::feed::Feed>,
70}
71
72impl WebServer<()> {
73 #[must_use]
75 pub fn new(host: impl Into<String>, port: u16, environment: Environment) -> Self {
76 Self::with_state(host, port, environment, ())
77 }
78
79 pub fn from_env() -> Result<Self, WebServerError> {
89 Self::from_env_with_state(())
90 }
91}
92
93impl<S> WebServer<S>
94where
95 S: Clone + Send + Sync + 'static,
96{
97 #[must_use]
99 pub fn with_state(
100 host: impl Into<String>,
101 port: u16,
102 environment: Environment,
103 app: S,
104 ) -> Self {
105 Self {
106 host: host.into(),
107 port,
108 environment,
109 app,
110 body_limit: DEFAULT_BODY_LIMIT,
111 root_dir: PathBuf::new(),
113 router: Router::new(),
114 #[cfg(feature = "pages")]
115 frontend: None,
116 #[cfg(feature = "pages")]
117 feed: None,
118 }
119 }
120
121 pub fn from_env_with_state(app: S) -> Result<Self, WebServerError> {
128 let environment: Environment = Environment::from_env()?;
129 let host: String = env::optional(ENV_HOST).unwrap_or_else(|| {
130 if environment.is_production() {
131 String::from("0.0.0.0")
132 } else {
133 String::from("127.0.0.1")
134 }
135 });
136 let port: u16 =
137 env::parse_or(ENV_PORT, "port number", DEFAULT_PORT).map_err(WebServerError::Env)?;
138
139 Ok(Self::with_state(host, port, environment, app))
140 }
141
142 #[must_use]
147 pub const fn body_limit(mut self, body_limit: usize) -> Self {
148 self.body_limit = body_limit;
149 self
150 }
151
152 #[must_use]
160 pub fn root_dir(mut self, root_dir: impl Into<PathBuf>) -> Self {
161 self.root_dir = root_dir.into();
162 self
163 }
164
165 #[must_use]
167 pub fn nest(mut self, path: &str, router: Router<WebServerState<S>>) -> Self {
168 self.router = self.router.nest(path, router);
169 self
170 }
171
172 #[must_use]
174 pub fn merge(mut self, router: Router<WebServerState<S>>) -> Self {
175 self.router = self.router.merge(router);
176 self
177 }
178
179 #[must_use]
181 pub fn nest_service<T>(mut self, path: &str, service: T) -> Self
182 where
183 T: tower::Service<axum::extract::Request, Error = std::convert::Infallible>
184 + Clone
185 + Send
186 + Sync
187 + 'static,
188 T::Response: axum::response::IntoResponse,
189 T::Future: Send + 'static,
190 {
191 self.router = self.router.nest_service(path, service);
192 self
193 }
194
195 #[cfg(feature = "pages")]
202 #[must_use]
203 pub fn frontend(mut self, params: super::frontend::FrontendParams<S>) -> Self {
204 self.frontend = Some(params);
205 self
206 }
207
208 #[cfg(feature = "pages")]
221 #[must_use]
222 pub fn feed(mut self, feed: crate::feed::Feed) -> Self {
223 self.feed = Some(feed);
224 self
225 }
226
227 pub fn into_router(self, shutdown: Shutdown) -> Result<Router, WebServerError> {
243 Ok(self.assemble(shutdown)?.router)
244 }
245
246 #[instrument(skip_all)]
253 pub async fn run(self, shutdown: Shutdown) -> Result<(), WebServerError>
254 where
255 S: AppShutdown,
256 {
257 let Assembled {
258 router,
259 state,
260 host,
261 port,
262 } = self.assemble(shutdown.clone())?;
263
264 serve_on(router, &host, port, shutdown, async move {
265 state.app().on_shutdown().await;
266 })
267 .await
268 }
269
270 #[instrument(skip_all)]
275 fn assemble(self, shutdown: Shutdown) -> Result<Assembled<S>, WebServerError> {
276 let Self {
277 host,
278 port,
279 environment,
280 app,
281 body_limit,
282 root_dir,
283 router,
284 #[cfg(feature = "pages")]
285 frontend,
286 #[cfg(feature = "pages")]
287 feed,
288 } = self;
289
290 #[cfg(feature = "pages")]
293 if feed.is_some() && frontend.is_none() {
294 return Err(WebServerError::FeedWithoutFrontend);
295 }
296
297 let cache_buster: crate::assets::CacheBuster =
300 crate::assets::CacheBuster::load_in(&root_dir)?;
301
302 let mut no_cache: Router<WebServerState<S>> = router;
303 let mut built_in: Router<WebServerState<S>> = Router::new().route("/health", get(health));
304
305 #[cfg(feature = "pages")]
306 let mut frontend_runtime: Option<crate::templates::FrontendRuntime> = None;
307 #[cfg(feature = "pages")]
308 let mut base: Option<crate::templates::BaseTemplateData> = None;
309 #[cfg(feature = "pages")]
310 let mut templates: Option<crate::templates::TemplateRegistry<'static>> = None;
311 #[cfg(feature = "pages")]
312 let mut not_found: Option<(
313 std::sync::Arc<crate::templates::PageTemplateData>,
314 std::sync::Arc<serde_json::Value>,
315 )> = None;
316 #[cfg(feature = "pages")]
317 let mut proxy_scripts: Option<Router<WebServerState<S>>> = None;
318 #[cfg(feature = "pages")]
319 let mut feed_router: Option<Router<WebServerState<S>>> = None;
320
321 log_immutable_assets(&cache_buster);
322
323 #[cfg(feature = "pages")]
324 if let Some(params) = frontend {
325 let assembled: AssembledFrontend<S> =
326 assemble_frontend(params, feed, &cache_buster, environment)?;
327
328 no_cache = no_cache.merge(assembled.routes);
329 built_in = built_in.merge(assembled.api);
330 proxy_scripts = Some(assembled.proxy_scripts);
331 feed_router = assembled.feeds;
332 not_found = Some(assembled.not_found);
333 frontend_runtime = Some(assembled.runtime);
334 base = Some(assembled.base);
335 templates = Some(assembled.templates);
336 }
337
338 let no_cache: Router<WebServerState<S>> = no_cache.nest(API_PREFIX, built_in);
339
340 let mut app_router: Router<WebServerState<S>> = apply_cache_policy(no_cache, &cache_buster);
341
342 #[cfg(feature = "pages")]
346 if let Some(scripts) = proxy_scripts {
347 app_router = app_router.merge(scripts);
348 }
349
350 #[cfg(feature = "pages")]
351 if let Some(feeds) = feed_router {
352 app_router = app_router.merge(feeds);
353 }
354
355 #[cfg(feature = "pages")]
356 let app_router: Router<WebServerState<S>> = attach_not_found(app_router, not_found);
357 #[cfg(not(feature = "pages"))]
358 let app_router: Router<WebServerState<S>> = app_router.fallback(plain_not_found);
359
360 let state: WebServerState<S> = WebServerState::new(StateParts {
361 host: host.clone(),
362 port,
363 environment,
364 shutdown,
365 #[cfg(feature = "templates")]
366 base,
367 #[cfg(feature = "templates")]
368 templates,
369 cache_buster: Some(cache_buster),
370 #[cfg(feature = "templates")]
371 frontend: frontend_runtime,
372 app,
373 });
374
375 let app_router: Router = app_router
380 .with_state(state.clone())
381 .layer(
382 TraceLayer::new_for_http()
383 .make_span_with(
389 DefaultMakeSpan::new()
390 .level(Level::INFO)
391 .include_headers(false),
392 )
393 .on_response(
394 DefaultOnResponse::new()
395 .level(Level::INFO)
396 .latency_unit(LatencyUnit::Millis),
397 ),
398 )
399 .layer(DefaultBodyLimit::max(body_limit));
400
401 Ok(Assembled {
402 router: app_router,
403 state,
404 host,
405 port,
406 })
407 }
408}
409
410struct Assembled<S> {
413 router: Router,
414 state: WebServerState<S>,
417 host: String,
418 port: u16,
419}
420
421#[cfg(feature = "pages")]
428fn attach_not_found<S>(
429 router: Router<WebServerState<S>>,
430 not_found: Option<(
431 std::sync::Arc<crate::templates::PageTemplateData>,
432 std::sync::Arc<serde_json::Value>,
433 )>,
434) -> Router<WebServerState<S>>
435where
436 S: Clone + Send + Sync + 'static,
437{
438 match not_found {
439 Some((page, data)) => router.fallback(move |axum::extract::State(state)| {
440 let page: std::sync::Arc<crate::templates::PageTemplateData> =
441 std::sync::Arc::clone(&page);
442 let data: std::sync::Arc<serde_json::Value> = std::sync::Arc::clone(&data);
443 async move {
444 let body: Response = super::pages::render_or_500(&state, &page, &data);
445 (StatusCode::NOT_FOUND, body).into_response()
446 }
447 }),
448 None => router.fallback(plain_not_found),
449 }
450}
451
452async fn serve_on<F>(
459 router: Router,
460 host: &str,
461 port: u16,
462 shutdown: Shutdown,
463 on_shutdown: F,
464) -> Result<(), WebServerError>
465where
466 F: Future<Output = ()> + Send,
467{
468 let ip: IpAddr = IpAddr::from_str(host).map_err(|source| WebServerError::Bind {
469 addr: SocketAddr::from(([0, 0, 0, 0], port)),
470 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, source),
471 })?;
472 let address: SocketAddr = SocketAddr::new(ip, port);
473 let listener: TcpListener =
474 TcpListener::bind(address)
475 .await
476 .map_err(|source| WebServerError::Bind {
477 addr: address,
478 source,
479 })?;
480
481 info!("listening on http://{address}");
482
483 let serving = serve(
487 listener,
488 router.into_make_service_with_connect_info::<SocketAddr>(),
489 )
490 .with_graceful_shutdown(shutdown.clone().recv())
491 .into_future();
492 tokio::pin!(serving);
493
494 tokio::select! {
498 result = &mut serving => return result.map_err(WebServerError::Serve),
499 () = shutdown.recv() => {}
500 }
501
502 let (served, cleaned) = tokio::join!(
507 tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, &mut serving),
508 tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, on_shutdown),
509 );
510
511 if cleaned.is_err() {
515 error!(
516 "app shutdown hook exceeded {DEFAULT_DRAIN_TIMEOUT:?}; cleanup was cancelled part-way"
517 );
518 }
519
520 match served {
521 Ok(result) => result.map_err(WebServerError::Serve),
522 Err(_elapsed) => {
525 warn!("drain window elapsed after {DEFAULT_DRAIN_TIMEOUT:?}; closing what remained");
526 Ok(())
527 }
528 }
529}
530
531fn apply_cache_policy<S>(
534 router: Router<WebServerState<S>>,
535 cache_buster: &crate::assets::CacheBuster,
536) -> Router<WebServerState<S>>
537where
538 S: Clone + Send + Sync + 'static,
539{
540 let router: Router<WebServerState<S>> = router.layer(axum::middleware::from_fn(
541 crate::assets::CacheBuster::never_cache_middleware,
542 ));
543
544 if cache_buster.is_empty() {
545 return router;
546 }
547
548 router.merge(
549 Router::new()
550 .nest_service(
551 "/static",
552 tower_http::services::ServeDir::new(
553 cache_buster.root().join(crate::assets::STATIC_DIRECTORY),
554 ),
555 )
556 .layer(axum::middleware::from_fn(
557 crate::assets::CacheBuster::forever_cache_middleware,
558 )),
559 )
560}
561
562async fn health() -> StatusCode {
565 StatusCode::OK
566}
567
568async fn plain_not_found() -> Response {
570 (StatusCode::NOT_FOUND, "404").into_response()
571}
572
573#[cfg(feature = "pages")]
580fn well_known_routes<S>(well_known: &super::frontend::WellKnown) -> Router<WebServerState<S>>
581where
582 S: Clone + Send + Sync + 'static,
583{
584 use axum::http::header;
585
586 fn text<S>(
587 router: Router<WebServerState<S>>,
588 path: &str,
589 content_type: &'static str,
590 body: String,
591 ) -> Router<WebServerState<S>>
592 where
593 S: Clone + Send + Sync + 'static,
594 {
595 router.route(
596 path,
597 get(move || {
598 let body: String = body.clone();
599 async move { ([(header::CONTENT_TYPE, content_type)], body) }
600 }),
601 )
602 }
603
604 let mut router: Router<WebServerState<S>> = Router::new();
605 router = text(
606 router,
607 "/robots.txt",
608 "text/plain; charset=utf-8",
609 well_known.robots_txt.clone(),
610 );
611 router = text(
612 router,
613 "/humans.txt",
614 "text/plain; charset=utf-8",
615 well_known.humans_txt.clone(),
616 );
617 router = text(
618 router,
619 "/site.webmanifest",
620 "application/manifest+json",
621 well_known.webmanifest.clone(),
622 );
623 router = text(
624 router,
625 crate::sitemap::SITEMAP_INDEX_PATH,
626 "application/xml",
627 well_known.sitemaps.index().to_string(),
628 );
629 for (index, chunk) in well_known.sitemaps.chunks().iter().enumerate() {
630 router = text(
631 router,
632 &format!("/sitemap-{}.xml", index + 1),
633 "application/xml",
634 chunk.clone(),
635 );
636 }
637 router
638}
639
640#[cfg(feature = "pages")]
646fn icon_routes<S>(
647 cache_buster: &crate::assets::CacheBuster,
648 has_svg_icon: bool,
649) -> Router<WebServerState<S>>
650where
651 S: Clone + Send + Sync + 'static,
652{
653 use tower_http::services::ServeFile;
654
655 let mut icons: Vec<(&str, String)> = vec![
656 (
657 "/favicon.ico",
658 String::from("static/image/favicon/favicon.ico"),
659 ),
660 (
661 "/apple-touch-icon.png",
662 String::from("static/image/favicon/apple-touch-icon.png"),
663 ),
664 (
665 "/icon-192.png",
666 String::from("static/image/favicon/icon-192.png"),
667 ),
668 (
669 "/icon-512.png",
670 String::from("static/image/favicon/icon-512.png"),
671 ),
672 ];
673 if has_svg_icon {
675 icons.push((
676 "/favicon.svg",
677 String::from("static/image/favicon/favicon.svg"),
678 ));
679 }
680
681 let mut router: Router<WebServerState<S>> = Router::new();
682 for (route, original) in icons {
683 router = router.nest_service(route, ServeFile::new(cache_buster.file(&original)));
685 }
686 router
687}
688
689#[cfg(feature = "pages")]
700fn feed_routes<S>(feeds: &crate::feed::FeedSet) -> Router<WebServerState<S>>
701where
702 S: Clone + Send + Sync + 'static,
703{
704 use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
705
706 let last_modified: HeaderValue = HeaderValue::from_str(
710 &feeds
711 .last_modified()
712 .format("%a, %d %b %Y %H:%M:%S GMT")
713 .to_string(),
714 )
715 .unwrap_or_else(|_| HeaderValue::from_static(""));
716 let cache_control: HeaderValue = HeaderValue::from_str(&format!(
717 "public, max-age={}",
718 crate::feed::FEED_MAX_AGE_SECONDS
719 ))
720 .unwrap_or_else(|_| HeaderValue::from_static("public"));
721
722 let mut router: Router<WebServerState<S>> = Router::new();
723 for document in feeds.documents() {
724 let body: String = String::from(document.body());
725 let etag_text: String = String::from(document.etag());
726 let etag: HeaderValue =
727 HeaderValue::from_str(&etag_text).unwrap_or_else(|_| HeaderValue::from_static(""));
728 let content_type: HeaderValue = HeaderValue::from_static(document.content_type());
729 let last_modified: HeaderValue = last_modified.clone();
730 let cache_control: HeaderValue = cache_control.clone();
731
732 router = router.route(
733 document.path(),
734 get(move |headers: HeaderMap| {
735 let body: String = body.clone();
736 let etag_text: String = etag_text.clone();
737 let etag: HeaderValue = etag.clone();
738 let content_type: HeaderValue = content_type.clone();
739 let last_modified: HeaderValue = last_modified.clone();
740 let cache_control: HeaderValue = cache_control.clone();
741 async move {
742 let fresh: bool = if_none_match(&headers, &etag_text);
743
744 let mut response: Response = if fresh {
749 StatusCode::NOT_MODIFIED.into_response()
750 } else {
751 (StatusCode::OK, body).into_response()
752 };
753
754 let headers: &mut HeaderMap = response.headers_mut();
755 headers.insert(header::CACHE_CONTROL, cache_control);
756 headers.insert(header::ETAG, etag);
757 headers.insert(header::LAST_MODIFIED, last_modified);
758 if !fresh {
759 headers.insert(header::CONTENT_TYPE, content_type);
760 }
761
762 response
763 }
764 }),
765 );
766 }
767
768 router
769}
770
771#[cfg(feature = "pages")]
777fn if_none_match(headers: &axum::http::HeaderMap, etag: &str) -> bool {
778 let Some(header) = headers
779 .get(axum::http::header::IF_NONE_MATCH)
780 .and_then(|value| value.to_str().ok())
781 else {
782 return false;
783 };
784
785 header.split(',').any(|candidate| {
786 let candidate: &str = candidate.trim();
787 candidate == "*" || candidate.trim_start_matches("W/") == etag
788 })
789}
790
791fn log_immutable_assets(cache_buster: &crate::assets::CacheBuster) {
801 for (original, hashed) in cache_buster.cache() {
802 info!("immutable 1y /{original} -> /{hashed}");
803 }
804}
805
806#[cfg(feature = "pages")]
808fn log_served_documents(well_known: &super::frontend::WellKnown) {
809 for path in ["/robots.txt", "/humans.txt", "/site.webmanifest"] {
810 info!("uncached {path}");
811 }
812 for path in well_known.sitemaps.paths() {
813 info!("uncached {path}");
814 }
815
816 if let Some(feeds) = well_known.feeds.as_ref() {
817 for document in feeds.documents() {
818 info!(
819 "cached {}m {} (etag {})",
820 crate::feed::FEED_MAX_AGE_SECONDS / 60,
821 document.path(),
822 document.etag()
823 );
824 }
825 }
826}
827
828#[cfg(feature = "pages")]
834struct AssembledFrontend<S> {
835 routes: Router<WebServerState<S>>,
837 api: Router<WebServerState<S>>,
839 proxy_scripts: Router<WebServerState<S>>,
841 feeds: Option<Router<WebServerState<S>>>,
843 runtime: crate::templates::FrontendRuntime,
844 base: crate::templates::BaseTemplateData,
845 templates: crate::templates::TemplateRegistry<'static>,
846 not_found: (
847 std::sync::Arc<crate::templates::PageTemplateData>,
848 std::sync::Arc<serde_json::Value>,
849 ),
850}
851
852#[cfg(feature = "pages")]
854fn assemble_frontend<S>(
855 params: super::frontend::FrontendParams<S>,
856 feed: Option<crate::feed::Feed>,
857 cache_buster: &crate::assets::CacheBuster,
858 environment: Environment,
859) -> Result<AssembledFrontend<S>, WebServerError>
860where
861 S: Clone + Send + Sync + 'static,
862{
863 let templates: crate::templates::TemplateRegistry<'static> =
864 crate::templates::TemplateRegistry::from_dir(
865 cache_buster.root().join(crate::templates::TEMPLATE_ROOT),
866 )?;
867
868 let built: super::frontend::Frontend<S> =
869 super::frontend::Frontend::build(params, feed, cache_buster, environment)?;
870
871 log_served_documents(&built.well_known);
872
873 let feeds: Option<Router<WebServerState<S>>> = built.well_known.feeds.as_ref().map(feed_routes);
877
878 let mut routes: Router<WebServerState<S>> = well_known_routes(&built.well_known);
879 routes = routes.merge(icon_routes(cache_buster, built.has_svg_icon));
880
881 let (proxy_scripts, api) = proxy_routes(&built);
885
886 let not_found = built.not_found.clone();
887 routes = routes.merge(built.pages.into_router());
888
889 Ok(AssembledFrontend {
890 routes,
891 api,
892 proxy_scripts,
893 feeds,
894 runtime: built.runtime,
895 base: built.base,
896 templates,
897 not_found,
898 })
899}
900
901#[cfg(feature = "pages")]
907fn proxy_routes<S>(
908 frontend: &super::frontend::Frontend<S>,
909) -> (Router<WebServerState<S>>, Router<WebServerState<S>>)
910where
911 S: Clone + Send + Sync + 'static,
912{
913 use crate::analytics::{AnalyticsConfig, relay_envelope, relay_event, relay_script};
914 use axum::body::Bytes;
915 use axum::extract::ConnectInfo;
916 use axum::http::HeaderMap;
917 use axum::routing::post;
918
919 let client: reqwest::Client = reqwest::Client::new();
920 let paths: &crate::templates::FrontendRuntime = &frontend.runtime;
921
922 let analytics_script_upstream: String = frontend.analytics.upstream_script_url();
923 let analytics_event_upstream: String = AnalyticsConfig::upstream_event_url();
924 let sentry_script_upstream: String = frontend.sentry_dsn.upstream_script_url();
925 let sentry_envelope_upstream: String = frontend.sentry_dsn.upstream_envelope_url();
926
927 let scripts: Router<WebServerState<S>> = Router::new()
928 .route(
929 &paths.analytics.script_path,
930 get({
931 let client: reqwest::Client = client.clone();
932 let upstream: std::sync::Arc<str> =
933 std::sync::Arc::from(analytics_script_upstream.as_str());
934 move || {
935 let client: reqwest::Client = client.clone();
936 let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
937 async move { relay_script(&client, &upstream).await }
938 }
939 }),
940 )
941 .route(
942 &paths.sentry_browser.script_path,
943 get({
944 let client: reqwest::Client = client.clone();
945 let upstream: std::sync::Arc<str> =
946 std::sync::Arc::from(sentry_script_upstream.as_str());
947 move || {
948 let client: reqwest::Client = client.clone();
949 let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
950 async move { relay_script(&client, &upstream).await }
951 }
952 }),
953 );
954
955 let event_path: String = strip_api_prefix(&paths.analytics.event_path);
957 let tunnel_path: String = strip_api_prefix(&paths.sentry_browser.tunnel_path);
958
959 let endpoints: Router<WebServerState<S>> = Router::new()
960 .route(
961 &event_path,
962 post({
963 let client: reqwest::Client = client.clone();
964 let upstream: std::sync::Arc<str> =
965 std::sync::Arc::from(analytics_event_upstream.as_str());
966 move |ConnectInfo(peer): ConnectInfo<SocketAddr>,
967 headers: HeaderMap,
968 body: Bytes| {
969 let client: reqwest::Client = client.clone();
970 let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
971 async move { relay_event(&client, &upstream, &headers, peer, body).await }
972 }
973 }),
974 )
975 .route(
976 &tunnel_path,
977 post({
978 let upstream: std::sync::Arc<str> =
979 std::sync::Arc::from(sentry_envelope_upstream.as_str());
980 move |body: Bytes| {
981 let client: reqwest::Client = client.clone();
982 let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
983 async move { relay_envelope(&client, &upstream, body).await }
984 }
985 }),
986 );
987
988 (scripts, endpoints)
989}
990
991#[cfg(feature = "pages")]
994fn strip_api_prefix(path: &str) -> String {
995 path.strip_prefix(API_PREFIX)
996 .map_or_else(|| path.to_string(), String::from)
997}
998
999#[cfg(test)]
1000mod tests {
1001
1002 #[cfg(feature = "pages")]
1003 mod conditional_get {
1004 use axum::http::{HeaderMap, HeaderValue, header};
1005
1006 use crate::webserver::server::if_none_match;
1007
1008 const ETAG: &str = "\"abc123\"";
1009
1010 fn headers(value: &str) -> HeaderMap {
1011 let mut headers: HeaderMap = HeaderMap::new();
1012 headers.insert(
1013 header::IF_NONE_MATCH,
1014 HeaderValue::from_str(value).expect("a valid header"),
1015 );
1016 headers
1017 }
1018
1019 #[test]
1020 fn a_request_without_the_header_always_gets_the_body() {
1021 let expected: bool = false;
1022 let actual: bool = if_none_match(&HeaderMap::new(), ETAG);
1023 assert_eq!(expected, actual);
1024 }
1025
1026 #[test]
1027 fn the_same_validator_means_the_reader_already_has_this_feed() {
1028 let expected: bool = true;
1029 let actual: bool = if_none_match(&headers(ETAG), ETAG);
1030 assert_eq!(expected, actual);
1031 }
1032
1033 #[test]
1034 fn a_weak_validator_still_matches_because_feeds_need_no_byte_equality() {
1035 let expected: bool = true;
1036 let actual: bool = if_none_match(&headers("W/\"abc123\""), ETAG);
1037 assert_eq!(expected, actual);
1038 }
1039
1040 #[test]
1041 fn a_star_matches_anything_that_exists() {
1042 let expected: bool = true;
1043 let actual: bool = if_none_match(&headers("*"), ETAG);
1044 assert_eq!(expected, actual);
1045 }
1046
1047 #[test]
1048 fn one_match_anywhere_in_the_list_is_enough() {
1049 let expected: bool = true;
1053 let actual: bool = if_none_match(&headers("\"other\", \"abc123\""), ETAG);
1054 assert_eq!(expected, actual);
1055 }
1056
1057 #[test]
1058 fn a_stale_validator_gets_the_new_document() {
1059 let expected: bool = false;
1060 let actual: bool = if_none_match(&headers("\"stale\""), ETAG);
1061 assert_eq!(expected, actual);
1062 }
1063 }
1064 use std::sync::Arc;
1065 use std::sync::atomic::{AtomicBool, Ordering};
1066 use std::time::Duration;
1067
1068 use axum::Router;
1069 use axum::routing::get;
1070 use tokio::time::timeout;
1071
1072 use super::super::shutdown::Shutdown;
1073 use super::{
1074 API_PREFIX, AppShutdown, DEFAULT_BODY_LIMIT, DEFAULT_DRAIN_TIMEOUT, WebServerError, health,
1075 serve_on,
1076 };
1077
1078 #[derive(Clone)]
1081 struct RecordingState {
1082 ran: Arc<AtomicBool>,
1083 linger: Option<Duration>,
1084 }
1085
1086 impl RecordingState {
1087 fn instant() -> Self {
1088 Self {
1089 ran: Arc::new(AtomicBool::new(false)),
1090 linger: None,
1091 }
1092 }
1093 }
1094
1095 impl AppShutdown for RecordingState {
1096 async fn on_shutdown(&self) {
1097 if let Some(linger) = self.linger {
1098 tokio::time::sleep(linger).await;
1099 }
1100 self.ran.store(true, Ordering::SeqCst);
1101 }
1102 }
1103
1104 async fn serve_until_shutdown(
1106 state: RecordingState,
1107 shutdown: Shutdown,
1108 ) -> Result<(), WebServerError> {
1109 let router: Router = Router::new().route("/health", get(health));
1110 let cleanup: RecordingState = state.clone();
1111 serve_on(router, "127.0.0.1", 0, shutdown, async move {
1113 cleanup.on_shutdown().await;
1114 })
1115 .await
1116 }
1117
1118 #[tokio::test]
1119 async fn app_cleanup_runs_when_the_shutdown_signal_arrives() {
1120 let shutdown: Shutdown = Shutdown::manual();
1121 let state: RecordingState = RecordingState::instant();
1122 let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1123
1124 shutdown.trigger();
1125 timeout(
1126 Duration::from_secs(1),
1127 serve_until_shutdown(state, shutdown.clone()),
1128 )
1129 .await
1130 .expect("serving ended promptly")
1131 .expect("serving ended cleanly");
1132
1133 let expected: bool = true;
1134 let actual: bool = ran.load(Ordering::SeqCst);
1135 assert_eq!(expected, actual);
1136 }
1137
1138 #[tokio::test]
1139 async fn app_cleanup_never_runs_when_the_server_dies_before_any_signal() {
1140 let shutdown: Shutdown = Shutdown::manual();
1144 let state: RecordingState = RecordingState::instant();
1145 let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1146 let cleanup: RecordingState = state.clone();
1147
1148 let router: Router = Router::new().route("/health", get(health));
1149 let result: Result<(), WebServerError> = timeout(
1150 Duration::from_secs(1),
1151 serve_on(router, "not-an-ip", 0, shutdown, async move {
1152 cleanup.on_shutdown().await;
1153 }),
1154 )
1155 .await
1156 .expect("a bind failure returns immediately, it does not wait for cleanup");
1157
1158 assert!(result.is_err(), "an unparseable host is a bind error");
1159
1160 let expected: bool = false;
1161 let actual: bool = ran.load(Ordering::SeqCst);
1162 assert_eq!(expected, actual);
1163 }
1164
1165 #[tokio::test(start_paused = true)]
1166 async fn a_cleanup_that_overruns_the_window_is_cancelled_rather_than_awaited() {
1167 let shutdown: Shutdown = Shutdown::manual();
1171 let state: RecordingState = RecordingState {
1172 ran: Arc::new(AtomicBool::new(false)),
1173 linger: Some(DEFAULT_DRAIN_TIMEOUT * 2),
1174 };
1175 let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1176
1177 shutdown.trigger();
1178 serve_until_shutdown(state, shutdown.clone())
1179 .await
1180 .expect("serving still ends cleanly when cleanup is cancelled");
1181
1182 let expected: bool = false;
1183 let actual: bool = ran.load(Ordering::SeqCst);
1184 assert_eq!(
1185 expected, actual,
1186 "the hook was cancelled at its await, so it never reached its final store"
1187 );
1188 }
1189
1190 #[tokio::test]
1191 async fn a_server_with_nothing_in_flight_stops_at_once_instead_of_waiting_out_the_window() {
1192 let shutdown: Shutdown = Shutdown::manual();
1193 let router: Router = Router::new().route("/health", get(health));
1194
1195 let serving: tokio::task::JoinHandle<Result<(), WebServerError>> = tokio::spawn({
1197 let shutdown: Shutdown = shutdown.clone();
1198 async move { serve_on(router, "127.0.0.1", 0, shutdown, async {}).await }
1199 });
1200
1201 shutdown.trigger();
1202
1203 timeout(Duration::from_secs(1), serving)
1206 .await
1207 .expect("the server returned as soon as the drain began")
1208 .expect("the serving task did not panic")
1209 .expect("serving ended cleanly");
1210 }
1211
1212 #[test]
1213 fn the_body_limit_accommodates_an_ordinary_form_post() {
1214 let expected: usize = 256 * 1024;
1217 let actual: usize = DEFAULT_BODY_LIMIT;
1218 assert_eq!(expected, actual);
1219 }
1220
1221 #[cfg(feature = "pages")]
1222 #[test]
1223 fn stripping_the_prefix_leaves_a_nestable_path() {
1224 let expected: String = String::from("/boggledygook-a3f2c1d8");
1225 let actual: String = super::strip_api_prefix("/api/v1/boggledygook-a3f2c1d8");
1226 assert_eq!(expected, actual);
1227 }
1228
1229 #[test]
1230 fn the_api_prefix_is_the_one_every_project_shares() {
1231 assert_eq!("/api/v1", API_PREFIX);
1232 }
1233}