Skip to main content

rws_server/app/
mod.rs

1#[cfg(test)]
2mod tests;
3
4pub mod controller;
5
6use std::sync::Arc;
7
8use crate::app::controller::directory_listing::DirectoryListingAssetsController;
9use crate::app::controller::favicon::FaviconController;
10use crate::app::controller::health::HealthController;
11use crate::app::controller::ready::ReadyController;
12use crate::app::controller::metrics::MetricsController;
13use crate::app::controller::file::initiate::FileUploadInitiateController;
14use crate::app::controller::form::get_method::FormGetMethodController;
15use crate::app::controller::form::multipart_enctype_post_method::FormMultipartEnctypePostMethodController;
16use crate::app::controller::form::url_encoded_enctype_post_method::FormUrlEncodedEnctypePostMethodController;
17use crate::app::controller::index::IndexController;
18use crate::app::controller::not_found::NotFoundController;
19use crate::app::controller::script::ScriptController;
20use crate::app::controller::static_resource::StaticResourceController;
21use crate::app::controller::style::StyleController;
22use crate::application::Application;
23use crate::controller::Controller;
24use crate::core::New;
25use crate::header::Header;
26use crate::mcp::McpServer;
27use crate::middleware::{Middleware, WithMiddleware};
28use crate::request::Request;
29use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
30use crate::server::ConnectionInfo;
31use crate::server_config::ServerConfig;
32use crate::state::AppWithState;
33
34/// A pair of function pointers representing one entry in the controller chain.
35struct ControllerEntry {
36    is_matching: fn(&Request, &ConnectionInfo) -> bool,
37    process: fn(&Request, Response, &ConnectionInfo) -> Response,
38}
39
40/// Build a [`ControllerEntry`] from any type that implements [`Controller`].
41fn entry<C: Controller>() -> ControllerEntry {
42    ControllerEntry {
43        is_matching: C::is_matching,
44        process: C::process,
45    }
46}
47
48/// The built-in HTTP application. Serves static files, favicons, forms,
49/// file uploads, health probes, metrics, and a 404 fallback.
50///
51/// Use as-is or compose with the framework's building blocks:
52///
53/// ```rust,no_run
54/// # extern crate rws_server as rust_web_server;
55/// use rust_web_server::app::App;
56/// use rust_web_server::middleware::{WithMiddleware, RateLimitLayer};
57/// use rust_web_server::core::New;
58///
59/// // Middleware stack around the built-in app
60/// let app = App::new().wrap(RateLimitLayer);
61/// ```
62///
63/// For user-defined routes with shared state, call [`App::with_state`]:
64///
65/// ```rust,no_run
66/// # extern crate rws_server as rust_web_server;
67/// use rust_web_server::app::App;
68/// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
69/// use rust_web_server::core::New;
70///
71/// struct State { version: &'static str }
72///
73/// let app = App::with_state(State { version: "1.0" })
74///     .get("/version", |_req, _params, _conn, state| {
75///         let mut r = Response::new();
76///         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
77///         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
78///         r
79///     });
80/// ```
81///
82/// # Per-instance configuration
83///
84/// By default `App::new()` reads CORS, CSP, and other settings from
85/// environment variables on each request (matching the current process state,
86/// including hot-reloaded values). Call [`App::with_config`] to pin the
87/// configuration to a specific [`ServerConfig`] — this is the recommended
88/// pattern for parallel integration tests, which should not touch environment
89/// variables at all.
90///
91/// ```rust,ignore
92/// # extern crate rws_server as rust_web_server;
93/// use rust_web_server::app::App;
94/// use rust_web_server::server_config::ServerConfig;
95/// use rust_web_server::test_client::TestClient;
96///
97/// // No env writes, no lock needed — safe to run in parallel.
98/// let app = App::with_config(ServerConfig {
99///     cors_allow_all: false,
100///     cors_allow_origins: "https://trusted.example.com".to_string(),
101///     ..ServerConfig::default()
102/// });
103/// let client = TestClient::new(app);
104/// ```
105#[derive(Clone)]
106pub struct App {
107    /// When `Some`, every request is served using this fixed config.
108    /// When `None`, config is read from environment variables on each request
109    /// (supports hot-reload via SIGHUP / `POST /admin/config/reload`).
110    config: Option<Arc<ServerConfig>>,
111}
112
113impl New for App {
114    fn new() -> Self {
115        App { config: None }
116    }
117}
118
119impl App {
120    /// Create an `App` pinned to an explicit [`ServerConfig`].
121    ///
122    /// All CORS, CSP, and other header settings are taken from `config` rather
123    /// than from environment variables. The configuration is fixed for the
124    /// lifetime of the `App` instance — SIGHUP / hot-reload do not affect it.
125    ///
126    /// This is the preferred constructor for integration tests: build a
127    /// [`ServerConfig`] with the exact settings under test, pass it here, and
128    /// use [`TestClient`] to drive requests. No environment writes and no
129    /// [`test_env::lock()`] are needed.
130    ///
131    /// [`TestClient`]: crate::test_client::TestClient
132    /// [`test_env::lock()`]: crate::test_env::lock
133    pub fn with_config(config: ServerConfig) -> Self {
134        App { config: Some(Arc::new(config)) }
135    }
136
137    /// [`App::with_config`] sharing an existing `Arc` instead of copying it.
138    #[doc(hidden)]
139    pub fn with_shared_config(config: Arc<ServerConfig>) -> Self {
140        App { config: Some(config) }
141    }
142
143    /// Whether the built-in controllers answer `request` without blocking:
144    /// every GET/HEAD they serve is in memory or a local file read (health,
145    /// metrics, assets, static files — large files are only opened here and
146    /// streamed from the blocking pool). Uploads and form posts aren't.
147    #[doc(hidden)]
148    pub fn is_inline_request(request: &Request) -> bool {
149        let read = request.method == crate::request::METHOD.get || request.method == crate::request::METHOD.head;
150        // A probe whose checks may block (a database ping) runs off the event loop.
151        read && !App::probe_request(request).is_some_and(|(probe, _)| crate::health::has_checks(probe))
152    }
153
154    /// The probe the `health` registry answers for `request`, and whether
155    /// `?verbose` was asked for.
156    fn probe_request(request: &Request) -> Option<(crate::health::Probe, bool)> {
157        if request.method != crate::request::METHOD.get && request.method != crate::request::METHOD.head {
158            return None;
159        }
160        let (path, query) = request.request_uri.split_once('?').unwrap_or((&request.request_uri, ""));
161        let probe = crate::health::probe_for_path(path)?;
162        let verbose = query.split('&').any(|p| p == "verbose" || p.starts_with("verbose="));
163        let legacy = path == "/healthz" || path == "/readyz";
164        (!legacy || verbose || crate::health::has_checks(probe)).then_some((probe, verbose))
165    }
166}
167
168impl Application for App {
169    // `App` dispatches through a fixed if-chain of built-in `Controller`s
170    // rather than a `Router`: the built-in set is small, static, and known
171    // at compile time, so a linear scan is simpler and just as fast as a
172    // segment-matching router would be here. This is a deliberate choice,
173    // not an oversight — `Router` (src/router/mod.rs) is the right tool for
174    // user-defined routes with named path params/wildcards, and `AppWithState`
175    // / `AsyncAppWithState` use exactly that, falling through to this same
176    // controller chain for anything they don't handle themselves.
177    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
178        let config = match &self.config {
179            Some(c) => Arc::clone(c),
180            None => ServerConfig::current(),
181        };
182        let header_list = Header::get_header_list_with_config(request, &config);
183        let response = crate::error::problem::with_headers(
184            STATUS_CODE_REASON_PHRASE.n501_not_implemented,
185            &format!("no handler for method {}", request.method),
186            "",
187            Some(header_list),
188        );
189
190        // Probes with registered checks, `?verbose`, `/livez`, `/startupz`
191        // (18.35.0): the `health` registry answers. Without checks
192        // `/healthz` and `/readyz` keep their controllers' answers.
193        if let Some((probe, verbose)) = App::probe_request(request) {
194            return Ok(crate::health::respond(probe, verbose));
195        }
196
197        let controllers = [
198            entry::<IndexController>(),
199            entry::<StyleController>(),
200            entry::<ScriptController>(),
201            entry::<DirectoryListingAssetsController>(),
202            entry::<FileUploadInitiateController>(),
203            entry::<FormUrlEncodedEnctypePostMethodController>(),
204            entry::<FormGetMethodController>(),
205            entry::<FormMultipartEnctypePostMethodController>(),
206            entry::<HealthController>(),
207            entry::<ReadyController>(),
208            entry::<MetricsController>(),
209            entry::<FaviconController>(),
210        ];
211
212        for c in &controllers {
213            if (c.is_matching)(request, connection) {
214                let mut response = (c.process)(request, response, connection);
215                // The site's own index.html is a static file: the static
216                // Cache-Control (default `no-cache`) instead of the dynamic
217                // `no-store`, which kept browsers from caching the home page
218                // at all (and out of their back/forward cache).
219                if IndexController::is_matching(request, connection)
220                    && response.status_code == *STATUS_CODE_REASON_PHRASE.n200_ok.status_code
221                    && file_ext::FileExt::does_file_exist(IndexController::INDEX_FILEPATH)
222                {
223                    crate::app::controller::static_resource::set_static_cache_control(&mut response, &config.static_cache_control);
224                }
225                return Ok(response);
226            }
227        }
228
229        // Static files, after every built-in route. A GET/HEAD of a regular
230        // file is answered in one pass — StaticResourceController::is_matching
231        // would build the path and look the file up, only for process to do
232        // both again; serving one implies is_matching would have said yes.
233        let mut response = response;
234        if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
235            return Ok(served);
236        }
237        if StaticResourceController::is_matching(request, connection)
238            && (config.directory_listing || !StaticResourceController::is_directory_without_index(request))
239        {
240            return Ok(StaticResourceController::process_general(request, response, &config));
241        }
242        // `/docs/http2/):` — a link copied with the prose around it.
243        if let Some(redirect) = StaticResourceController::trailing_punctuation_redirect(request) {
244            return Ok(redirect);
245        }
246        if NotFoundController::is_matching(request, connection) {
247            return Ok(NotFoundController::process(request, response, connection));
248        }
249
250        Ok(response)
251    }
252
253    fn runs_inline(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
254        App::is_inline_request(request)
255    }
256}
257
258impl App {
259    /// Dispatch `request` through the controller chain and return the response.
260    ///
261    /// This is a convenience wrapper over [`Application::execute`] that uses a
262    /// synthetic loopback [`ConnectionInfo`]. Use it in tests or when no real
263    /// connection context is available. Prefer [`TestClient`] for structured
264    /// test code.
265    ///
266    /// [`TestClient`]: crate::test_client::TestClient
267    pub fn handle_request(request: Request) -> (Response, Request) {
268        use crate::server::Address;
269        let conn = ConnectionInfo {
270            client: Address { ip: "127.0.0.1".to_string(), port: 0 },
271            server: Address { ip: "127.0.0.1".to_string(), port: 7878 },
272            request_size: 16000,
273            sni_hostname: None,
274            tls: false,
275        };
276        let app = App::new();
277        let response = app.execute(&request, &conn).unwrap_or_else(|_| {
278            let header_list = Header::get_header_list_with_config(&request, &ServerConfig::current());
279            Response::get_response(
280                STATUS_CODE_REASON_PHRASE.n500_internal_server_error,
281                Some(header_list),
282                None,
283            )
284        });
285        (response, request)
286    }
287
288    /// Create a state-aware application. Routes registered on the returned
289    /// [`AppWithState<S>`] are tried first; unmatched requests fall through to
290    /// the built-in controller chain (static files, health probes, etc.).
291    ///
292    /// The state is stored as `Arc<S>` and shared across all handlers.
293    ///
294    /// # Example
295    ///
296    /// ```rust,no_run
297    /// # extern crate rws_server as rust_web_server;
298    /// use rust_web_server::app::App;
299    /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
300    /// use rust_web_server::core::New;
301    ///
302    /// struct Db { url: String }
303    ///
304    /// let app = App::with_state(Db { url: "postgres://...".to_string() })
305    ///     .get("/ping", |_req, _params, _conn, db| {
306    ///         let mut r = Response::new();
307    ///         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
308    ///         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
309    ///         r
310    ///     });
311    /// ```
312    pub fn with_state<S: Send + Sync + 'static>(state: S) -> AppWithState<S> {
313        AppWithState::new(state)
314    }
315
316    /// Wrap this application in a middleware layer.
317    ///
318    /// Returns a [`WithMiddleware<App>`] that runs `layer` before every
319    /// request. Chain `.wrap()` calls to stack multiple layers:
320    ///
321    /// ```rust,no_run
322    /// # extern crate rws_server as rust_web_server;
323    /// use rust_web_server::app::App;
324    /// use rust_web_server::middleware::RateLimitLayer;
325    /// use rust_web_server::core::New;
326    ///
327    /// let app = App::new().wrap(RateLimitLayer);
328    /// ```
329    pub fn wrap<M: Middleware + 'static>(self, layer: M) -> WithMiddleware<App> {
330        WithMiddleware::new(self).wrap(layer)
331    }
332
333    /// Attach an MCP server to this application. Tools, resources, and
334    /// prompts are registered on the returned [`McpServer`]; requests that
335    /// do not match the MCP endpoint are forwarded to `self` (static files,
336    /// health probes, any custom routes registered before this call).
337    ///
338    /// ```rust,no_run
339    /// # extern crate rws_server as rust_web_server;
340    /// use rust_web_server::app::App;
341    /// use rust_web_server::mcp::{McpContent, extract_arg};
342    /// use rust_web_server::core::New;
343    ///
344    /// // Pure MCP — unmatched paths handled by built-in App
345    /// let app = App::new()
346    ///     .mcp("my-server", "1.0")
347    ///     .tool(
348    ///         "echo",
349    ///         "Echo text back",
350    ///         r#"{"type":"object","properties":{"text":{"type":"string"}}}"#,
351    ///         |args| Ok(McpContent::text(extract_arg(args, "text").unwrap_or_default())),
352    ///     );
353    /// ```
354    ///
355    /// To combine with custom HTTP routes, start from [`App::with_state`]:
356    ///
357    /// ```rust,no_run
358    /// # extern crate rws_server as rust_web_server;
359    /// use rust_web_server::app::App;
360    /// use rust_web_server::mcp::McpContent;
361    /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
362    /// use rust_web_server::core::New;
363    ///
364    /// let app = App::with_state(())
365    ///     .get("/api/ping", |_, _, _, _| {
366    ///         let mut r = Response::new();
367    ///         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
368    ///         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
369    ///         r
370    ///     })
371    ///     .mcp("my-server", "1.0")
372    ///     .tool("ping", "Ping the server", "{}", |_| Ok(McpContent::text("pong")));
373    /// ```
374    pub fn mcp(self, name: impl Into<String>, version: impl Into<String>) -> McpServer {
375        McpServer::new(name, version).wrap(self)
376    }
377
378    /// Create an async state-aware application (requires the `http2` feature).
379    ///
380    /// Handlers are `async fn` closures that can `await` database queries,
381    /// HTTP clients, or any other async I/O. Unmatched routes fall through to
382    /// the built-in controller chain.
383    ///
384    /// # Example
385    ///
386    /// ```rust,no_run
387    /// # extern crate rws_server as rust_web_server;
388    /// use std::sync::Arc;
389    /// use rust_web_server::app::App;
390    /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
391    /// use rust_web_server::core::New;
392    ///
393    /// struct Db { url: String }
394    ///
395    /// let app = App::with_async_state(Db { url: "postgres://...".to_string() })
396    ///     .get("/ping", |_req, _params, _conn, state| async move {
397    ///         // state: Arc<Db>
398    ///         let mut r = Response::new();
399    ///         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
400    ///         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
401    ///         r
402    ///     });
403    /// ```
404    #[cfg(feature = "http2")]
405    pub fn with_async_state<S: Send + Sync + 'static>(state: S) -> crate::async_state::AsyncAppWithState<S> {
406        crate::async_state::AsyncAppWithState::new(state)
407    }
408}