Pārlūkot izejas kodu

Merge pull request #109 from sd2k/add-serve-proxy

Add minimal proxy capabilities to `dioxus serve`
YuKun Liu 2 gadi atpakaļ
vecāks
revīzija
1587b687ca
7 mainītis faili ar 240 papildinājumiem un 12 dzēšanām
  1. 15 0
      Cargo.lock
  2. 1 0
      Cargo.toml
  3. 13 0
      docs/src/configure.md
  4. 7 0
      src/config.rs
  5. 6 0
      src/error.rs
  6. 27 12
      src/server/mod.rs
  7. 171 0
      src/server/proxy.rs

+ 15 - 0
Cargo.lock

@@ -715,6 +715,7 @@ dependencies = [
  "headers",
  "html_parser",
  "hyper",
+ "hyper-rustls",
  "indicatif",
  "lazy_static",
  "log",
@@ -1329,7 +1330,9 @@ checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c"
 dependencies = [
  "http",
  "hyper",
+ "log",
  "rustls",
+ "rustls-native-certs",
  "tokio",
  "tokio-rustls",
 ]
@@ -2330,6 +2333,18 @@ dependencies = [
  "webpki",
 ]
 
+[[package]]
+name = "rustls-native-certs"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
+dependencies = [
+ "openssl-probe",
+ "rustls-pemfile",
+ "schannel",
+ "security-framework",
+]
+
 [[package]]
 name = "rustls-pemfile"
 version = "1.0.2"

+ 1 - 0
Cargo.toml

@@ -36,6 +36,7 @@ regex = "1.5.4"
 chrono = "0.4.19"
 anyhow = "1.0.53"
 hyper = "0.14.17"
+hyper-rustls = "0.23.2"
 indicatif = "0.17.0-rc.11"
 subprocess = "0.2.9"
 

+ 13 - 0
docs/src/configure.md

@@ -119,6 +119,16 @@ Only include resources at `Dev` mode.
     ]
    ```
 
+### Web.Proxy
+
+Proxy requests matching a path to a backend server.
+
+1. ***backend*** - the URL to the backend server.
+   ```
+   backend = "http://localhost:8000/api/"
+   ```
+   This will cause any requests made to the dev server with prefix /api/ to be redirected to the backend server at http://localhost:8000. The path and query parameters will be passed on as-is (path rewriting is not currently supported).
+
 ## Config example
 
 ```toml
@@ -168,4 +178,7 @@ style = []
 
 # Javascript code file
 script = []
+
+[[web.proxy]]
+backend = "http://localhost:8000/api/"
 ```

+ 7 - 0
src/config.rs

@@ -63,6 +63,7 @@ impl Default for DioxusConfig {
                     title: Some("dioxus | ⛺".into()),
                     base_path: None,
                 },
+                proxy: Some(vec![]),
                 watcher: WebWatcherConfig {
                     watch_path: Some(vec![PathBuf::from("src")]),
                     reload_html: Some(false),
@@ -97,6 +98,7 @@ pub struct ApplicationConfig {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct WebConfig {
     pub app: WebAppConfig,
+    pub proxy: Option<Vec<WebProxyConfig>>,
     pub watcher: WebWatcherConfig,
     pub resource: WebResourceConfig,
 }
@@ -107,6 +109,11 @@ pub struct WebAppConfig {
     pub base_path: Option<String>,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WebProxyConfig {
+    pub backend: String,
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct WebWatcherConfig {
     pub watch_path: Option<Vec<PathBuf>>,

+ 6 - 0
src/error.rs

@@ -32,6 +32,12 @@ pub enum Error {
     #[error("{0}")]
     CustomError(String),
 
+    #[error("Invalid proxy URL: {0}")]
+    InvalidProxy(#[from] hyper::http::uri::InvalidUri),
+
+    #[error("Error proxying request: {0}")]
+    ProxyRequestError(hyper::Error),
+
     #[error(transparent)]
     Other(#[from] anyhow::Error),
 }

+ 27 - 12
src/server/mod.rs

@@ -23,6 +23,8 @@ use tokio::sync::broadcast;
 use tower::ServiceBuilder;
 use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};
 
+mod proxy;
+
 pub struct BuildManager {
     config: CrateConfig,
     reload_tx: broadcast::Sender<()>,
@@ -284,16 +286,18 @@ pub async fn startup_hot_reload(ip: String, port: u16, config: CrateConfig) -> R
         )
         .service(ServeDir::new(config.crate_dir.join(&dist_path)));
 
-    let router = Router::new()
-        .route("/_dioxus/ws", get(ws_handler))
-        .fallback(
-            get_service(file_service).handle_error(|error: std::io::Error| async move {
-                (
-                    StatusCode::INTERNAL_SERVER_ERROR,
-                    format!("Unhandled internal error: {}", error),
-                )
-            }),
-        );
+    let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
+    for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() {
+        router = proxy::add_proxy(router, &proxy_config )?;
+    }
+    router = router.fallback(get_service(file_service).handle_error(
+        |error: std::io::Error| async move {
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                format!("Unhandled internal error: {}", error),
+            )
+        },
+    ));
 
     let router = router
         .route("/_dioxus/hot_reload", get(hot_reload_handler))
@@ -427,8 +431,9 @@ pub async fn startup_default(ip: String, port: u16, config: CrateConfig) -> Resu
         )
         .service(ServeDir::new(config.crate_dir.join(&dist_path)));
 
-    let router = Router::new()
-        .route("/_dioxus/ws", get(ws_handler))
+    let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
+
+    router = router
         .fallback(
             get_service(file_service).handle_error(|error: std::io::Error| async move {
                 (
@@ -498,6 +503,8 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre
         "False"
     };
 
+    let proxies = config.dioxus_config.web.proxy.as_ref();
+
     if options.changed.is_empty() {
         println!(
             "{} @ v{} [{}] \n",
@@ -528,6 +535,14 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre
     println!("");
     println!("\t> Profile : {}", profile.green());
     println!("\t> Hot Reload : {}", hot_reload.cyan());
+    if let Some(proxies) = proxies {
+        if !proxies.is_empty() {
+            println!("\t> Proxies :");
+            for proxy in proxies {
+                println!("\t\t- {}", proxy.backend.blue());
+            }
+        }
+    }
     println!("\t> Index Template : {}", custom_html_file.green());
     println!("\t> URL Rewrite [index_on_404] : {}", url_rewrite.purple());
     println!("");

+ 171 - 0
src/server/proxy.rs

@@ -0,0 +1,171 @@
+use crate::{Result, WebProxyConfig};
+
+use anyhow::Context;
+use axum::{http::StatusCode, routing::any, Router};
+use hyper::{Request, Response, Uri};
+
+#[derive(Debug, Clone)]
+struct ProxyClient {
+    inner: hyper::Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>>,
+    url: Uri,
+}
+
+impl ProxyClient {
+    fn new(url: Uri) -> Self {
+        let https = hyper_rustls::HttpsConnectorBuilder::new()
+            .with_native_roots()
+            .https_or_http()
+            .enable_http1()
+            .build();
+        Self {
+            inner: hyper::Client::builder().build(https),
+            url,
+        }
+    }
+
+    async fn send(
+        &self,
+        mut req: Request<hyper::body::Body>,
+    ) -> Result<Response<hyper::body::Body>> {
+        let mut uri_parts = req.uri().clone().into_parts();
+        uri_parts.authority = self.url.authority().cloned();
+        uri_parts.scheme = self.url.scheme().cloned();
+        *req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?;
+        self.inner
+            .request(req)
+            .await
+            .map_err(crate::error::Error::ProxyRequestError)
+    }
+}
+
+/// Add routes to the router handling the specified proxy config.
+///
+/// We will proxy requests directed at either:
+///
+/// - the exact path of the proxy config's backend URL, e.g. /api
+/// - the exact path with a trailing slash, e.g. /api/
+/// - any subpath of the backend URL, e.g. /api/foo/bar
+pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
+    let url: Uri = proxy.backend.parse()?;
+    let path = url.path().to_string();
+    let client = ProxyClient::new(url);
+
+    // We also match everything after the path using a wildcard matcher.
+    let wildcard_client = client.clone();
+
+    router = router.route(
+        // Always remove trailing /'s so that the exact route
+        // matches.
+        path.trim_end_matches('/'),
+        any(move |req| async move {
+            client
+                .send(req)
+                .await
+                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
+        }),
+    );
+
+    // Wildcard match anything else _after_ the backend URL's path.
+    // Note that we know `path` ends with a trailing `/` in this branch,
+    // so `wildcard` will look like `http://localhost/api/*proxywildcard`.
+    let wildcard = format!("{}/*proxywildcard", path.trim_end_matches('/'));
+    router = router.route(
+        &wildcard,
+        any(move |req| async move {
+            wildcard_client
+                .send(req)
+                .await
+                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
+        }),
+    );
+    Ok(router)
+}
+
+#[cfg(test)]
+mod test {
+
+    use super::*;
+
+    use axum::{extract::Path, Router};
+
+    fn setup_servers(
+        mut config: WebProxyConfig,
+    ) -> (
+        tokio::task::JoinHandle<()>,
+        tokio::task::JoinHandle<()>,
+        String,
+    ) {
+        let backend_router = Router::new().route(
+            "/*path",
+            any(|path: Path<String>| async move { format!("backend: {}", path.0) }),
+        );
+        let backend_server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
+            .serve(backend_router.into_make_service());
+        let backend_addr = backend_server.local_addr();
+        let backend_handle = tokio::spawn(async move { backend_server.await.unwrap() });
+        config.backend = format!("http://{}{}", backend_addr, config.backend);
+        let router = super::add_proxy(Router::new(), &config);
+        let server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
+            .serve(router.unwrap().into_make_service());
+        let server_addr = server.local_addr();
+        let server_handle = tokio::spawn(async move { server.await.unwrap() });
+        (backend_handle, server_handle, server_addr.to_string())
+    }
+
+    async fn test_proxy_requests(path: String) {
+        let config = WebProxyConfig {
+            // Normally this would be an absolute URL including scheme/host/port,
+            // but in these tests we need to let the OS choose the port so tests
+            // don't conflict, so we'll concatenate the final address and this
+            // path together.
+            // So in day to day usage, use `http://localhost:8000/api` instead!
+            backend: path,
+        };
+        let (backend_handle, server_handle, server_addr) = setup_servers(config);
+        let resp = hyper::Client::new()
+            .get(format!("http://{}/api", server_addr).parse().unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        assert_eq!(
+            hyper::body::to_bytes(resp.into_body()).await.unwrap(),
+            "backend: /api"
+        );
+
+        let resp = hyper::Client::new()
+            .get(format!("http://{}/api/", server_addr).parse().unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        assert_eq!(
+            hyper::body::to_bytes(resp.into_body()).await.unwrap(),
+            "backend: /api/"
+        );
+
+        let resp = hyper::Client::new()
+            .get(
+                format!("http://{}/api/subpath", server_addr)
+                    .parse()
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        assert_eq!(
+            hyper::body::to_bytes(resp.into_body()).await.unwrap(),
+            "backend: /api/subpath"
+        );
+        backend_handle.abort();
+        server_handle.abort();
+    }
+
+    #[tokio::test]
+    async fn add_proxy() {
+        test_proxy_requests("/api".to_string()).await;
+    }
+
+    #[tokio::test]
+    async fn add_proxy_trailing_slash() {
+        test_proxy_requests("/api/".to_string()).await;
+    }
+}