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 request.method == crate::request::METHOD.get || request.method == crate::request::METHOD.head
150 }
151}
152
153impl Application for App {
154 // `App` dispatches through a fixed if-chain of built-in `Controller`s
155 // rather than a `Router`: the built-in set is small, static, and known
156 // at compile time, so a linear scan is simpler and just as fast as a
157 // segment-matching router would be here. This is a deliberate choice,
158 // not an oversight — `Router` (src/router/mod.rs) is the right tool for
159 // user-defined routes with named path params/wildcards, and `AppWithState`
160 // / `AsyncAppWithState` use exactly that, falling through to this same
161 // controller chain for anything they don't handle themselves.
162 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
163 let config = match &self.config {
164 Some(c) => Arc::clone(c),
165 None => ServerConfig::current(),
166 };
167 let header_list = Header::get_header_list_with_config(request, &config);
168 let response = crate::error::problem::with_headers(
169 STATUS_CODE_REASON_PHRASE.n501_not_implemented,
170 &format!("no handler for method {}", request.method),
171 "",
172 Some(header_list),
173 );
174
175 let controllers = [
176 entry::<IndexController>(),
177 entry::<StyleController>(),
178 entry::<ScriptController>(),
179 entry::<DirectoryListingAssetsController>(),
180 entry::<FileUploadInitiateController>(),
181 entry::<FormUrlEncodedEnctypePostMethodController>(),
182 entry::<FormGetMethodController>(),
183 entry::<FormMultipartEnctypePostMethodController>(),
184 entry::<HealthController>(),
185 entry::<ReadyController>(),
186 entry::<MetricsController>(),
187 entry::<FaviconController>(),
188 ];
189
190 for c in &controllers {
191 if (c.is_matching)(request, connection) {
192 return Ok((c.process)(request, response, connection));
193 }
194 }
195
196 // Static files, after every built-in route. A GET/HEAD of a regular
197 // file is answered in one pass — StaticResourceController::is_matching
198 // would build the path and look the file up, only for process to do
199 // both again; serving one implies is_matching would have said yes.
200 let mut response = response;
201 if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
202 return Ok(served);
203 }
204 if StaticResourceController::is_matching(request, connection)
205 && (config.directory_listing || !StaticResourceController::is_directory_without_index(request))
206 {
207 return Ok(StaticResourceController::process_general(request, response, &config));
208 }
209 // `/docs/http2/):` — a link copied with the prose around it.
210 if let Some(redirect) = StaticResourceController::trailing_punctuation_redirect(request) {
211 return Ok(redirect);
212 }
213 if NotFoundController::is_matching(request, connection) {
214 return Ok(NotFoundController::process(request, response, connection));
215 }
216
217 Ok(response)
218 }
219
220 fn runs_inline(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
221 App::is_inline_request(request)
222 }
223}
224
225impl App {
226 /// Dispatch `request` through the controller chain and return the response.
227 ///
228 /// This is a convenience wrapper over [`Application::execute`] that uses a
229 /// synthetic loopback [`ConnectionInfo`]. Use it in tests or when no real
230 /// connection context is available. Prefer [`TestClient`] for structured
231 /// test code.
232 ///
233 /// [`TestClient`]: crate::test_client::TestClient
234 pub fn handle_request(request: Request) -> (Response, Request) {
235 use crate::server::Address;
236 let conn = ConnectionInfo {
237 client: Address { ip: "127.0.0.1".to_string(), port: 0 },
238 server: Address { ip: "127.0.0.1".to_string(), port: 7878 },
239 request_size: 16000,
240 sni_hostname: None,
241 tls: false,
242 };
243 let app = App::new();
244 let response = app.execute(&request, &conn).unwrap_or_else(|_| {
245 let header_list = Header::get_header_list_with_config(&request, &ServerConfig::current());
246 Response::get_response(
247 STATUS_CODE_REASON_PHRASE.n500_internal_server_error,
248 Some(header_list),
249 None,
250 )
251 });
252 (response, request)
253 }
254
255 /// Create a state-aware application. Routes registered on the returned
256 /// [`AppWithState<S>`] are tried first; unmatched requests fall through to
257 /// the built-in controller chain (static files, health probes, etc.).
258 ///
259 /// The state is stored as `Arc<S>` and shared across all handlers.
260 ///
261 /// # Example
262 ///
263 /// ```rust,no_run
264 /// # extern crate rws_server as rust_web_server;
265 /// use rust_web_server::app::App;
266 /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
267 /// use rust_web_server::core::New;
268 ///
269 /// struct Db { url: String }
270 ///
271 /// let app = App::with_state(Db { url: "postgres://...".to_string() })
272 /// .get("/ping", |_req, _params, _conn, db| {
273 /// let mut r = Response::new();
274 /// r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
275 /// r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
276 /// r
277 /// });
278 /// ```
279 pub fn with_state<S: Send + Sync + 'static>(state: S) -> AppWithState<S> {
280 AppWithState::new(state)
281 }
282
283 /// Wrap this application in a middleware layer.
284 ///
285 /// Returns a [`WithMiddleware<App>`] that runs `layer` before every
286 /// request. Chain `.wrap()` calls to stack multiple layers:
287 ///
288 /// ```rust,no_run
289 /// # extern crate rws_server as rust_web_server;
290 /// use rust_web_server::app::App;
291 /// use rust_web_server::middleware::RateLimitLayer;
292 /// use rust_web_server::core::New;
293 ///
294 /// let app = App::new().wrap(RateLimitLayer);
295 /// ```
296 pub fn wrap<M: Middleware + 'static>(self, layer: M) -> WithMiddleware<App> {
297 WithMiddleware::new(self).wrap(layer)
298 }
299
300 /// Attach an MCP server to this application. Tools, resources, and
301 /// prompts are registered on the returned [`McpServer`]; requests that
302 /// do not match the MCP endpoint are forwarded to `self` (static files,
303 /// health probes, any custom routes registered before this call).
304 ///
305 /// ```rust,no_run
306 /// # extern crate rws_server as rust_web_server;
307 /// use rust_web_server::app::App;
308 /// use rust_web_server::mcp::{McpContent, extract_arg};
309 /// use rust_web_server::core::New;
310 ///
311 /// // Pure MCP — unmatched paths handled by built-in App
312 /// let app = App::new()
313 /// .mcp("my-server", "1.0")
314 /// .tool(
315 /// "echo",
316 /// "Echo text back",
317 /// r#"{"type":"object","properties":{"text":{"type":"string"}}}"#,
318 /// |args| Ok(McpContent::text(extract_arg(args, "text").unwrap_or_default())),
319 /// );
320 /// ```
321 ///
322 /// To combine with custom HTTP routes, start from [`App::with_state`]:
323 ///
324 /// ```rust,no_run
325 /// # extern crate rws_server as rust_web_server;
326 /// use rust_web_server::app::App;
327 /// use rust_web_server::mcp::McpContent;
328 /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
329 /// use rust_web_server::core::New;
330 ///
331 /// let app = App::with_state(())
332 /// .get("/api/ping", |_, _, _, _| {
333 /// let mut r = Response::new();
334 /// r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
335 /// r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
336 /// r
337 /// })
338 /// .mcp("my-server", "1.0")
339 /// .tool("ping", "Ping the server", "{}", |_| Ok(McpContent::text("pong")));
340 /// ```
341 pub fn mcp(self, name: impl Into<String>, version: impl Into<String>) -> McpServer {
342 McpServer::new(name, version).wrap(self)
343 }
344
345 /// Create an async state-aware application (requires the `http2` feature).
346 ///
347 /// Handlers are `async fn` closures that can `await` database queries,
348 /// HTTP clients, or any other async I/O. Unmatched routes fall through to
349 /// the built-in controller chain.
350 ///
351 /// # Example
352 ///
353 /// ```rust,no_run
354 /// # extern crate rws_server as rust_web_server;
355 /// use std::sync::Arc;
356 /// use rust_web_server::app::App;
357 /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
358 /// use rust_web_server::core::New;
359 ///
360 /// struct Db { url: String }
361 ///
362 /// let app = App::with_async_state(Db { url: "postgres://...".to_string() })
363 /// .get("/ping", |_req, _params, _conn, state| async move {
364 /// // state: Arc<Db>
365 /// let mut r = Response::new();
366 /// r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
367 /// r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
368 /// r
369 /// });
370 /// ```
371 #[cfg(feature = "http2")]
372 pub fn with_async_state<S: Send + Sync + 'static>(state: S) -> crate::async_state::AsyncAppWithState<S> {
373 crate::async_state::AsyncAppWithState::new(state)
374 }
375}