Skip to main content

autumn_plugin_d3/
plugin.rs

1//! [`D3Plugin`]: installs the D3 assets in an Autumn app.
2//!
3//! The plugin installs [`D3_ASSETS`] through `AppBuilder::plugin_assets`.
4//! It reads no configuration and adds no startup hooks.
5
6use std::borrow::Cow;
7
8use autumn_web::app::AppBuilder;
9use autumn_web::plugin::Plugin;
10
11use crate::assets::D3_ASSETS;
12
13/// The plugin name in Autumn diagnostics.
14pub const PLUGIN_NAME: &str = "autumn-plugin-d3";
15
16/// Installs the D3 assets in an Autumn app.
17///
18/// ```rust,no_run
19/// use autumn_plugin_d3::D3Plugin;
20///
21/// # async fn run() {
22/// autumn_web::app()
23///     .plugin(D3Plugin::new())
24///     .run()
25///     .await;
26/// # }
27/// ```
28///
29/// Then put [`d3_stylesheet`](crate::d3_stylesheet) and
30/// [`d3_script`](crate::d3_script) in the page, and render a
31/// [`Chart`](crate::Chart).
32#[derive(Debug, Default, Clone, Copy)]
33#[must_use]
34pub struct D3Plugin;
35
36impl D3Plugin {
37    /// Makes the plugin.
38    pub const fn new() -> Self {
39        Self
40    }
41}
42
43impl Plugin for D3Plugin {
44    fn name(&self) -> Cow<'static, str> {
45        Cow::Borrowed(PLUGIN_NAME)
46    }
47
48    fn build(self, app: AppBuilder) -> AppBuilder {
49        app.plugin_assets(&D3_ASSETS)
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::assets::{D3_CSS, D3_JS, INIT_JS, PARSE_JS};
57    use autumn_web::assets::{PLUGIN_ASSETS_ROUTE_MARKER, asset_url};
58    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
59    use autumn_web::route_listing::{RouteClassification, RouteSource};
60    use autumn_web::test::{TestApp, TestClient};
61
62    const JS: &str = "text/javascript; charset=utf-8";
63    const CSS: &str = "text/css; charset=utf-8";
64    const IMMUTABLE: &str = "public, max-age=31536000, immutable";
65    const REVALIDATE: &str = "public, max-age=0, must-revalidate";
66    const FILES: [&str; 4] = [D3_JS, PARSE_JS, INIT_JS, D3_CSS];
67
68    fn client() -> TestClient {
69        TestApp::new().plugin(D3Plugin::new()).build()
70    }
71
72    #[tokio::test]
73    async fn every_file_serves_at_its_hashed_url() {
74        let client = client();
75        for path in FILES {
76            let response = client.get(&D3_ASSETS.url(path)).send().await;
77            let ty = if path == D3_CSS { CSS } else { JS };
78            response
79                .assert_ok()
80                .assert_header("content-type", ty)
81                .assert_header("cache-control", IMMUTABLE);
82            assert_eq!(
83                response.body.as_slice(),
84                D3_ASSETS.get(path).expect("bundled").bytes(),
85                "{path}"
86            );
87        }
88    }
89
90    #[tokio::test]
91    async fn d3_js_is_the_umd_build() {
92        let response = client().get(&D3_ASSETS.url(D3_JS)).send().await;
93        assert!(response.text().contains("d3js.org v7.9.0"));
94    }
95
96    #[tokio::test]
97    async fn plain_urls_serve_with_revalidation_and_etags() {
98        let client = client();
99        for path in FILES {
100            let plain = format!("/static/_plugins/d3/{path}");
101            let response = client.get(&plain).send().await;
102            response
103                .assert_ok()
104                .assert_header("cache-control", REVALIDATE);
105            let etag = response.header("etag").expect("etag").to_owned();
106            client
107                .get(&plain)
108                .header("if-none-match", &etag)
109                .send()
110                .await
111                .assert_status(304);
112        }
113    }
114
115    #[tokio::test]
116    async fn provenance_and_stale_paths_are_not_found() {
117        let client = client();
118        for path in [
119            "/static/_plugins/d3/manifest.json",
120            "/static/_plugins/d3/D3-LICENSE",
121            "/static/_plugins/d3/init.00000000.js",
122            "/static/_plugins/d3/nope.js",
123        ] {
124            client.get(path).send().await.assert_status(404);
125        }
126    }
127
128    #[tokio::test]
129    async fn asset_url_resolves_the_installed_bundle() {
130        let _client = client();
131        for path in FILES {
132            assert_eq!(
133                asset_url(&format!("_plugins/d3/{path}")),
134                D3_ASSETS.url(path)
135            );
136        }
137    }
138
139    #[test]
140    fn bundle_routes_are_public_plugin_routes() {
141        let app = autumn_web::app().plugin(D3Plugin::new());
142        let infos = app.plugin_route_infos().expect("route infos");
143        let routes: Vec<_> = infos
144            .iter()
145            .filter(|info| info.path.starts_with("/static/_plugins/d3/"))
146            .collect();
147        assert_eq!(routes.len(), 8, "four files, two URLs each: {infos:?}");
148        for info in routes {
149            assert_eq!(info.method, "GET");
150            assert_eq!(info.classification, RouteClassification::Public);
151            assert_eq!(info.middleware, [PLUGIN_ASSETS_ROUTE_MARKER]);
152            assert_eq!(info.source, RouteSource::Plugin(PLUGIN_NAME.to_owned()));
153        }
154    }
155
156    #[test]
157    fn plugin_passes_conformance() {
158        let app = autumn_web::app().plugin(D3Plugin::new());
159        let infos = app.plugin_route_infos().expect("route infos");
160        let report = run_conformance(&ConformanceConfig::new(PLUGIN_NAME), &infos);
161        assert!(report.passed(), "{}", report.to_text_report());
162    }
163
164    #[tokio::test]
165    async fn installing_the_plugin_twice_is_harmless() {
166        let client = TestApp::new()
167            .plugin(D3Plugin::new())
168            .plugin(D3Plugin::new())
169            .build();
170        client.get(&D3_ASSETS.url(INIT_JS)).send().await.assert_ok();
171    }
172
173    #[test]
174    fn name_is_the_crate_name() {
175        assert_eq!(D3Plugin::new().name(), PLUGIN_NAME);
176    }
177}