ripht_php_sapi/adapters/
web.rs

1//! Web request builder for HTTP-style execution.
2//!
3//! Populates `$_SERVER`, `$_GET`, `$_POST`, `$_COOKIE`, and handles
4//! the mapping from HTTP semantics to PHP superglobals.
5
6use std::path::{Path, PathBuf};
7
8use crate::execution::ExecutionContext;
9use crate::sapi::ServerVars;
10
11#[cfg(feature = "tracing")]
12use tracing::debug;
13
14/// Errors from building a web request.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub enum WebRequestError {
18    MissingMethod,
19    InvalidMethod(String),
20    ScriptNotFound(PathBuf),
21}
22
23impl std::fmt::Display for WebRequestError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::MissingMethod => write!(f, "HTTP method not specified"),
27            Self::InvalidMethod(m) => write!(f, "Invalid HTTP method: {}", m),
28            Self::ScriptNotFound(path) => {
29                write!(f, "Script not found: {}", path.display())
30            }
31        }
32    }
33}
34
35impl std::error::Error for WebRequestError {}
36
37/// HTTP request method.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Method {
40    Get,
41    Post,
42    Put,
43    Delete,
44    Patch,
45    Head,
46    Options,
47}
48
49impl TryFrom<&str> for Method {
50    type Error = String;
51
52    fn try_from(value: &str) -> Result<Self, Self::Error> {
53        match value.to_uppercase().as_str() {
54            "GET" => Ok(Method::Get),
55            "POST" => Ok(Method::Post),
56            "PUT" => Ok(Method::Put),
57            "DELETE" => Ok(Method::Delete),
58            "PATCH" => Ok(Method::Patch),
59            "HEAD" => Ok(Method::Head),
60            "OPTIONS" => Ok(Method::Options),
61            _ => Err(format!("Invalid HTTP method: {}", value)),
62        }
63    }
64}
65
66impl TryFrom<String> for Method {
67    type Error = String;
68
69    fn try_from(value: String) -> Result<Self, Self::Error> {
70        Method::try_from(value.as_str())
71    }
72}
73
74impl std::fmt::Display for Method {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80impl Method {
81    pub fn as_str(&self) -> &'static str {
82        match &self {
83            Method::Get => "GET",
84            Method::Post => "POST",
85            Method::Put => "PUT",
86            Method::Delete => "DELETE",
87            Method::Patch => "PATCH",
88            Method::Head => "HEAD",
89            Method::Options => "OPTIONS",
90        }
91    }
92}
93
94/// Builder for web-style PHP requests.
95///
96/// Use the `with_*` methods to configure headers, cookies, body, and server
97/// info, then call `build()` to create an [`ExecutionContext`].
98#[derive(Debug, Clone)]
99pub struct WebRequest {
100    https: bool,
101    body: Vec<u8>,
102    server_port: u16,
103    remote_port: u16,
104    uri: Option<String>,
105    remote_addr: String,
106    server_addr: String,
107    method: Option<Method>,
108    server_name: String,
109    server_protocol: String,
110    path_info: Option<String>,
111    headers: Vec<(String, String)>,
112    cookies: Vec<(String, String)>,
113    document_root: Option<PathBuf>,
114    env_vars: Vec<(String, String)>,
115    ini_overrides: Vec<(String, String)>,
116}
117
118impl Default for WebRequest {
119    fn default() -> Self {
120        Self {
121            uri: None,
122            method: None,
123            server_name: "localhost".to_string(),
124            server_port: 80,
125            server_protocol: "HTTP/1.1".to_string(),
126            remote_addr: "127.0.0.1".to_string(),
127            remote_port: 0,
128            server_addr: "127.0.0.1".to_string(),
129            https: false,
130            headers: Vec::new(),
131            cookies: Vec::new(),
132            body: Vec::new(),
133            document_root: None,
134            path_info: None,
135            env_vars: Vec::new(),
136            ini_overrides: vec![
137                ("log_errors".to_string(), "1".to_string()),
138                ("html_errors".to_string(), "0".to_string()),
139                ("request_order".to_string(), "GP".to_string()),
140                ("display_errors".to_string(), "1".to_string()),
141                ("implicit_flush".to_string(), "0".to_string()),
142                ("variables_order".to_string(), "EGPCS".to_string()),
143                ("output_buffering".to_string(), "4096".to_string()),
144            ],
145        }
146    }
147}
148
149impl WebRequest {
150    #[must_use]
151    pub fn new(method: Method) -> Self {
152        Self {
153            method: Some(method),
154            ..Default::default()
155        }
156    }
157
158    #[must_use]
159    pub fn get() -> Self {
160        Self::new(Method::Get)
161    }
162
163    #[must_use]
164    pub fn post() -> Self {
165        Self::new(Method::Post)
166    }
167
168    #[must_use]
169    pub fn put() -> Self {
170        Self::new(Method::Put)
171    }
172
173    #[must_use]
174    pub fn delete() -> Self {
175        Self::new(Method::Delete)
176    }
177
178    #[must_use]
179    pub fn patch() -> Self {
180        Self::new(Method::Patch)
181    }
182
183    #[must_use]
184    pub fn head() -> Self {
185        Self::new(Method::Head)
186    }
187
188    #[must_use]
189    pub fn options() -> Self {
190        Self::new(Method::Options)
191    }
192
193    #[must_use]
194    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
195        self.uri = Some(uri.into());
196        self
197    }
198
199    #[must_use]
200    pub fn with_header(
201        mut self,
202        name: impl Into<String>,
203        value: impl Into<String>,
204    ) -> Self {
205        self.headers
206            .push((name.into(), value.into()));
207        self
208    }
209
210    #[must_use]
211    pub fn with_headers<I, K, V>(mut self, iter: I) -> Self
212    where
213        I: IntoIterator<Item = (K, V)>,
214        K: Into<String>,
215        V: Into<String>,
216    {
217        self.headers.extend(
218            iter.into_iter()
219                .map(|(k, v)| (k.into(), v.into())),
220        );
221        self
222    }
223
224    #[must_use]
225    pub fn with_cookie(
226        mut self,
227        name: impl Into<String>,
228        value: impl Into<String>,
229    ) -> Self {
230        self.cookies
231            .push((name.into(), value.into()));
232        self
233    }
234
235    #[must_use]
236    pub fn with_cookies<I, K, V>(mut self, iter: I) -> Self
237    where
238        I: IntoIterator<Item = (K, V)>,
239        K: Into<String>,
240        V: Into<String>,
241    {
242        self.cookies.extend(
243            iter.into_iter()
244                .map(|(k, v)| (k.into(), v.into())),
245        );
246        self
247    }
248
249    #[must_use]
250    pub fn with_body(mut self, bytes: impl Into<Vec<u8>>) -> Self {
251        self.body = bytes.into();
252        self
253    }
254
255    #[must_use]
256    pub fn with_content_type(self, ct: impl Into<String>) -> Self {
257        self.with_header("Content-Type", ct)
258    }
259
260    #[must_use]
261    pub fn with_raw_cookie_header(
262        self,
263        cookie_string: impl Into<String>,
264    ) -> Self {
265        self.with_header("Cookie", cookie_string)
266    }
267
268    #[must_use]
269    pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
270        self.server_name = name.into();
271        self
272    }
273
274    #[must_use]
275    pub fn with_server_port(mut self, port: u16) -> Self {
276        self.server_port = port;
277        self
278    }
279
280    #[must_use]
281    pub fn with_server_protocol(mut self, proto: impl Into<String>) -> Self {
282        self.server_protocol = proto.into();
283        self
284    }
285
286    #[must_use]
287    pub fn with_remote_addr(mut self, addr: impl Into<String>) -> Self {
288        self.remote_addr = addr.into();
289        self
290    }
291
292    #[must_use]
293    pub fn with_remote_port(mut self, port: u16) -> Self {
294        self.remote_port = port;
295        self
296    }
297
298    #[must_use]
299    pub fn with_server_addr(mut self, addr: impl Into<String>) -> Self {
300        self.server_addr = addr.into();
301        self
302    }
303
304    #[must_use]
305    pub fn with_https(mut self, enabled: bool) -> Self {
306        self.https = enabled;
307        self
308    }
309
310    #[must_use]
311    pub fn with_document_root(mut self, path: impl Into<PathBuf>) -> Self {
312        self.document_root = Some(path.into());
313        self
314    }
315
316    #[must_use]
317    pub fn with_path_info(mut self, path: impl Into<String>) -> Self {
318        self.path_info = Some(path.into());
319        self
320    }
321
322    #[must_use]
323    pub fn with_env(
324        mut self,
325        key: impl Into<String>,
326        value: impl Into<String>,
327    ) -> Self {
328        self.env_vars
329            .push((key.into(), value.into()));
330
331        self
332    }
333
334    #[must_use]
335    pub fn with_envs<I, K, V>(mut self, iter: I) -> Self
336    where
337        I: IntoIterator<Item = (K, V)>,
338        K: Into<String>,
339        V: Into<String>,
340    {
341        self.env_vars.extend(
342            iter.into_iter()
343                .map(|(k, v)| (k.into(), v.into())),
344        );
345
346        self
347    }
348
349    #[must_use]
350    pub fn with_ini(
351        mut self,
352        key: impl Into<String>,
353        value: impl Into<String>,
354    ) -> Self {
355        self.ini_overrides
356            .push((key.into(), value.into()));
357
358        self
359    }
360
361    #[must_use]
362    pub fn with_ini_overrides<I, K, V>(mut self, iter: I) -> Self
363    where
364        I: IntoIterator<Item = (K, V)>,
365        K: Into<String>,
366        V: Into<String>,
367    {
368        self.ini_overrides.extend(
369            iter.into_iter()
370                .map(|(k, v)| (k.into(), v.into())),
371        );
372
373        self
374    }
375
376    pub fn build(
377        self,
378        script_path: impl AsRef<Path>,
379    ) -> Result<ExecutionContext, WebRequestError> {
380        let method = self
381            .method
382            .ok_or(WebRequestError::MissingMethod)?;
383
384        #[cfg(feature = "tracing")]
385        debug!(
386            method = %method,
387            uri = ?self.uri,
388            "Building Web request"
389        );
390
391        let script_path = script_path
392            .as_ref()
393            .to_path_buf();
394
395        if !script_path.exists() {
396            return Err(WebRequestError::ScriptNotFound(script_path));
397        }
398
399        let script_filename = std::fs::canonicalize(&script_path)
400            .unwrap_or_else(|_| script_path.clone());
401
402        let uri = self.uri.unwrap_or_else(|| {
403            format!(
404                "/{}",
405                script_filename
406                    .file_name()
407                    .map(|s| s
408                        .to_string_lossy()
409                        .into_owned())
410                    .unwrap_or_default()
411            )
412        });
413
414        let document_root = self
415            .document_root
416            .unwrap_or_else(|| {
417                script_filename
418                    .parent()
419                    .map(|p| p.to_path_buf())
420                    .unwrap_or_else(|| PathBuf::from("/"))
421            });
422
423        let (path, query_string) = parse_uri(&uri);
424
425        let mut vars = ServerVars::web_defaults();
426
427        vars.request_method(method.as_str())
428            .request_uri(&uri)
429            .query_string(&query_string.unwrap_or_default())
430            .script_filename(&script_filename)
431            .script_name(&path)
432            .document_root(&document_root)
433            .server_name(&self.server_name)
434            .server_port(self.server_port)
435            .server_addr(&self.server_addr)
436            .remote_addr(&self.remote_addr)
437            .remote_port(self.remote_port)
438            .server_protocol(&self.server_protocol)
439            .https(self.https);
440
441        if let Some(ref path_info) = self.path_info {
442            vars.path_info(path_info, &document_root);
443        }
444
445        if !self.cookies.is_empty() {
446            let cookie_str = self
447                .cookies
448                .iter()
449                .map(|(k, v)| format!("{}={}", k, v))
450                .collect::<Vec<_>>()
451                .join("; ");
452
453            vars.cookies(&cookie_str);
454        }
455
456        let mut has_content_type = false;
457        let mut has_content_length = false;
458
459        for (name, value) in &self.headers {
460            if name.eq_ignore_ascii_case("Content-Type") {
461                has_content_type = true;
462            } else if name.eq_ignore_ascii_case("Content-Length") {
463                has_content_length = true;
464            }
465            vars.http_header(name, value);
466        }
467
468        if !has_content_type && !self.body.is_empty() {
469            vars.content_type("application/octet-stream");
470        }
471
472        if !has_content_length && !self.body.is_empty() {
473            vars.content_length(self.body.len());
474        }
475
476        Ok(ExecutionContext {
477            script_path,
478            server_vars: vars,
479            input: self.body,
480            env_vars: self.env_vars,
481            ini_overrides: self.ini_overrides,
482            log_to_stderr: false,
483        })
484    }
485}
486
487fn parse_uri(uri: &str) -> (String, Option<String>) {
488    match uri.find('?') {
489        Some(pos) => (uri[..pos].to_string(), Some(uri[pos + 1..].to_string())),
490        None => (uri.to_string(), None),
491    }
492}
493
494#[cfg(feature = "http")]
495mod http_compat {
496    use super::*;
497
498    pub fn from_http_request<B: AsRef<[u8]>>(
499        req: http::Request<B>,
500        script_path: impl AsRef<Path>,
501    ) -> Result<ExecutionContext, WebRequestError> {
502        let (parts, body) = req.into_parts();
503        from_http_parts(parts, body.as_ref().to_vec(), script_path)
504    }
505
506    pub fn from_http_parts(
507        parts: http::request::Parts,
508        body: Vec<u8>,
509        script_path: impl AsRef<Path>,
510    ) -> Result<ExecutionContext, WebRequestError> {
511        let method = Method::try_from(parts.method.as_str()).map_err(|_| {
512            WebRequestError::InvalidMethod(parts.method.to_string())
513        })?;
514
515        let mut builder =
516            WebRequest::new(method).with_uri(parts.uri.to_string());
517
518        for (name, value) in parts.headers.iter() {
519            if let Ok(value_str) = value.to_str() {
520                builder = builder.with_header(name.as_str(), value_str);
521            }
522        }
523
524        builder = builder.with_body(body);
525
526        builder.build(script_path)
527    }
528}
529
530#[cfg(feature = "http")]
531pub use http_compat::{from_http_parts, from_http_request};
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use std::path::PathBuf;
537
538    fn php_script_path(name: &str) -> PathBuf {
539        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
540            .join("tests/php_scripts")
541            .join(name)
542    }
543
544    #[test]
545    fn test_web_request_sets_log_to_stderr_false() {
546        let script_path = php_script_path("hello.php");
547        let ctx = WebRequest::get()
548            .build(&script_path)
549            .expect("failed to build Web request");
550
551        assert!(
552            !ctx.log_to_stderr,
553            "Web request should set log_to_stderr to false"
554        );
555    }
556
557    #[test]
558    fn test_web_execution_captures_messages() {
559        use crate::RiphtSapi;
560
561        let sapi = RiphtSapi::instance();
562        let script_path = php_script_path("error_log_test.php");
563
564        let ctx = WebRequest::get()
565            .build(&script_path)
566            .expect("failed to build Web request");
567
568        let result = sapi
569            .execute(ctx)
570            .expect("execution should succeed");
571
572        assert!(
573            result
574                .all_messages()
575                .any(|_| true),
576            "Web execution should capture error_log messages"
577        );
578
579        assert!(
580            result
581                .all_messages()
582                .any(|m| m
583                    .message
584                    .contains("Test error log message")),
585            "Should contain the error_log message"
586        );
587    }
588}