diff --git a/README.md b/README.md index a0429b5..4ba60b0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ console.log(response.body.toString()) * `docroot` {String} Document root for PHP. **Default:** process.cwd() * `throwRequestErrors` {Boolean} Throw request errors rather than returning responses with error codes. **Default:** false + * `rewriter` {Rewriter} Optional rewrite rules. **Default:** `undefined` * Returns: {Php} Construct a new PHP instance to which to dispatch requests. @@ -560,9 +561,111 @@ headers.forEach((value, name, headers) => { }) ``` +### `new Rewriter(input)` + +* `rules` {Array} The set of rewriting rules to apply to each request + * `operation` {String} Operation type (`and` or `or`) **Default:** `and` + * `conditions` {Array} Conditions to match against the request + * `type` {String} Condition type + * `args` {String} Arguments to pass to condition constructor + * `rewriters` {Array} Rewrites to apply if the conditions match + * `type` {String} Rewriter type + * `args` {String} Arguments to pass to rewriter constructor +* Returns: {Rewriter} + +Construct a Rewriter to rewrite requests before they are dispatched to PHP. + +```js +import { Rewriter } from '@platformatic/php-node' + +const rewriter = new Rewriter([{ + conditions: [{ + type: 'header', + args: ['User-Agent', '^(Mozilla|Chrome)'] + }], + rewriters: [{ + type: 'path', + args: ['^/old-path/(.*)$', '/new-path/$1'] + }] +}]) +``` + +#### Conditions + +There are several types of conditions which may be used to match against the +request. Each condition type has a set of arguments which are passed to the +constructor of the condition. The condition will be evaluated against the +request and if it matches, the rewriters will be applied. + +The available condition types are: + +- `exists` Matches if request path exists in docroot. +- `not_exists` Matches if request path does not exist in docroot. +- `header(name, pattern)` Matches named header against a pattern. + - `name` {String} The name of the header to match. + - `pattern` {String} The regex pattern to match against the header value. +- `method(pattern)` Matches request method against a pattern. + - `pattern` {String} The regex pattern to match against the HTTP method. +- `path(pattern)`: Matches request path against a pattern. + - `pattern` {String} The regex pattern to match against the request path. + +#### Rewriters + +There are several types of rewriters which may be used to rewrite the request +before it is dispatched to PHP. Each rewriter type has a set of arguments which +are passed to the constructor of the rewriter. The rewriter will be applied to +the request if the conditions match. + +The available rewriter types are: + +- `header(name, replacement)` Sets a named header to a given replacement. + - `name` {String} The name of the header to set. + - `replacement` {String} The replacement string to use for named header. +- `href(pattern, replacement)` Rewrites request path, query, and fragment to + given replacement. + - `pattern` {String} The regex pattern to match against the request path. + - `replacement` {String} The replacement string to use for request path. +- `method(replacement)` Sets the request method to a given replacement. + - `replacement` {String} The replacement string to use for request method. +- `path(pattern, replacement)` Rewrites request path to given replacement. + - `pattern` {String} The regex pattern to match against the request path. + - `replacement` {String} The replacement string to use for request path. + +### `rewriter.rewrite(request, docroot)` + +- `request` {Object} The request object. +- `docroot` {String} The document root. + +Rewrites the given request using the rules provided to the rewriter. + +This is mainly exposed for testing purposes. It is not recommended to use +directly. Rather, the `rewriter` should be provided to the `Php` constructor +to allow rewriting to occur within the PHP environment where it will be aware +of the original `REQUEST_URI` state. + +```js +import { Rewriter } from '@platformatic/php-node' + +const rewriter = new Rewriter([{ + rewriters: [{ + type: 'path', + args: ['^(.*)$', '/base/$1' + }] +}]) + +const request = new Request({ + url: 'http://example.com/foo/bar' +}) + +const modified = rewriter.rewrite(request, import.meta.dirname) + +console.log(modified.url) // http://example.com/base/foo/bar +``` + ## Contributing -This project is part of the [Platformatic](https://github.com/platformatic) ecosystem. Please refer to the main repository for contribution guidelines. +This project is part of the [Platformatic](https://github.com/platformatic) +ecosystem. Please refer to the main repository for contribution guidelines. ## License diff --git a/__test__/handler.spec.mjs b/__test__/handler.spec.mjs index a388df1..c86c2d7 100644 --- a/__test__/handler.spec.mjs +++ b/__test__/handler.spec.mjs @@ -3,6 +3,7 @@ import test from 'ava' import { Php, Request } from '../index.js' import { MockRoot } from './util.mjs' +import { Rewriter } from '../index.js' test('Support input/output streams', async (t) => { const mockroot = await MockRoot.from({ @@ -167,3 +168,36 @@ test('Allow receiving true errors', async (t) => { message: /^Script not found: .*\/index\.php$/ }, 'should throw error') }) + +test('Accept rewriter', async (t) => { + const mockroot = await MockRoot.from({ + 'index.php': '' + }) + t.teardown(() => mockroot.clean()) + + const rewriter = new Rewriter([ + { + conditions: [ + { type: 'path', args: ['^/rewrite_me$'] } + ], + rewriters: [ + { type: 'path', args: ['^/rewrite_me$', '/index.php'] } + ] + } + ]) + + const php = new Php({ + argv: process.argv, + docroot: mockroot.path, + throwRequestErrors: true, + rewriter + }) + + const req = new Request({ + url: 'http://example.com/rewrite_me' + }) + + const res = await php.handleRequest(req) + t.is(res.status, 200) + t.is(res.body.toString('utf8'), 'Hello, World!') +}) diff --git a/__test__/rewriter.spec.mjs b/__test__/rewriter.spec.mjs new file mode 100644 index 0000000..964d28e --- /dev/null +++ b/__test__/rewriter.spec.mjs @@ -0,0 +1,288 @@ +import test from 'ava' + +import { Request, Rewriter } from '../index.js' + +const docroot = import.meta.dirname + +test('existence condition', (t) => { + const req = new Request({ + method: 'GET', + url: 'http://example.com/util.mjs', + headers: { + TEST: ['foo'] + } + }) + + const rewriter = new Rewriter([ + { + conditions: [ + { type: 'exists' } + ], + rewriters: [ + { + type: 'path', + args: ['.*', '/404'] + } + ] + } + ]) + + t.is(rewriter.rewrite(req, docroot).url, 'http://example.com/404') +}) + +test('non-existence condition', (t) => { + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php', + headers: { + TEST: ['foo'] + } + }) + + const rewriter = new Rewriter([ + { + conditions: [ + { type: 'not_exists' } + ], + rewriters: [ + { + type: 'path', + args: ['.*', '/404'] + } + ] + } + ]) + + t.is(rewriter.rewrite(req, docroot).url, 'http://example.com/404') +}) + +test('condition groups - AND', (t) => { + const rewriter = new Rewriter([{ + conditions: [ + { type: 'header', args: ['TEST', 'foo'] }, + { type: 'path', args: ['^(/index.php)$'] } + ], + rewriters: [ + { type: 'path', args: ['^(/index.php)$', '/foo$1'] } + ] + }]) + + // Both conditions match, so rewrite is applied + { + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php', + headers: { + TEST: ['foo'] + } + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/foo/index.php' + ) + } + + // Header condition does not match, so rewrite is not applied + { + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/index.php' + ) + } + + // Path condition does not match, so rewrite is not applied + { + const req = new Request({ + method: 'GET', + url: 'http://example.com/nope.php', + headers: { + TEST: ['foo'] + } + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/nope.php' + ) + } +}) + +test('condition groups - OR', (t) => { + const rewriter = new Rewriter([{ + operation: 'or', + conditions: [ + { type: 'method', args: ['GET'] }, + { type: 'path', args: ['^(/index.php)$'] } + ], + rewriters: [ + { type: 'path', args: ['^(.*)$', '/foo$1'] } + ] + }]) + + // Both conditions match, so rewrite is applied + { + const req = new Request({ + url: 'http://example.com/index.php' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/foo/index.php' + ) + } + + // Path condition matches, so rewrite is applied + { + const req = new Request({ + url: 'http://example.com/index.php' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/foo/index.php' + ) + } + + // Header condition matches, so rewrite is applied + { + const req = new Request({ + url: 'http://example.com/nope.php' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/foo/nope.php' + ) + } + + // Neither condition matches, so rewrite is not applied + { + const req = new Request({ + method: 'POST', + url: 'http://example.com/nope.php' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/nope.php' + ) + } +}) + +test('header rewriting', (t) => { + const rewriter = new Rewriter([{ + rewriters: [ + { type: 'header', args: ['TEST', '(.*)', '${1}bar'] } + ] + }]) + + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php', + headers: { + TEST: ['foo'] + } + }) + + t.is(rewriter.rewrite(req, docroot).headers.get('TEST'), 'foobar') +}) + +test('href rewriting', (t) => { + const rewriter = new Rewriter([{ + rewriters: [ + { type: 'href', args: [ '^(.*)$', '/index.php?route=${1}' ] } + ] + }]) + + const req = new Request({ + url: 'http://example.com/foo/bar' + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/index.php?route=/foo/bar' + ) +}) + +test('method rewriting', (t) => { + const rewriter = new Rewriter([{ + rewriters: [ + { type: 'method', args: ['GET', 'POST'] } + ] + }]) + + const req = new Request({ + url: 'http://example.com/index.php' + }) + + t.is(rewriter.rewrite(req, docroot).method, 'POST') +}) + +test('path rewriting', (t) => { + const rewriter = new Rewriter([{ + rewriters: [ + { type: 'path', args: ['^(/index.php)$', '/foo$1'] } + ] + }]) + + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php', + headers: { + TEST: ['foo'] + } + }) + + t.is(rewriter.rewrite(req, docroot).url, 'http://example.com/foo/index.php') +}) + +test('rewriter sequencing', (t) => { + const rewriter = new Rewriter([{ + conditions: [ + { type: 'path', args: ['^(/index.php)$'] } + ], + rewriters: [ + { type: 'path', args: ['^(/index.php)$', '/bar$1'] }, + { type: 'path', args: ['^(/bar)', '/foo$1'] } + ] + }]) + + // Condition matches, and both rewriters are applied in sequence + { + const req = new Request({ + method: 'GET', + url: 'http://example.com/index.php', + headers: { + TEST: ['foo'] + } + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/foo/bar/index.php' + ) + } + + // Condition does not match, so no rewrites are applied even if the second + // rewriter would match + { + const req = new Request({ + method: 'GET', + url: 'http://example.com/bar/baz.php', + headers: { + TEST: ['foo'] + } + }) + + t.is( + rewriter.rewrite(req, docroot).url, + 'http://example.com/bar/baz.php' + ) + } +}) diff --git a/crates/lang_handler/Cargo.toml b/crates/lang_handler/Cargo.toml index df1d4cf..093d61e 100644 --- a/crates/lang_handler/Cargo.toml +++ b/crates/lang_handler/Cargo.toml @@ -17,4 +17,5 @@ cbindgen = "0.28.0" [dependencies] bytes = "1.10.1" +regex = "1.11.1" url = "2.5.4" diff --git a/crates/lang_handler/src/handler.rs b/crates/lang_handler/src/handler.rs index 40d1d2c..b334c40 100644 --- a/crates/lang_handler/src/handler.rs +++ b/crates/lang_handler/src/handler.rs @@ -1,4 +1,4 @@ -use crate::{Request, Response}; +use super::{Request, Response}; /// Enables a type to support handling HTTP requests. /// @@ -23,6 +23,7 @@ use crate::{Request, Response}; /// } /// } pub trait Handler { + /// The type of error that can occur while handling a request. type Error; /// Handles an HTTP request. @@ -50,7 +51,7 @@ pub trait Handler { /// # /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("invalid url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// diff --git a/crates/lang_handler/src/headers.rs b/crates/lang_handler/src/headers.rs index 18f1607..9430580 100644 --- a/crates/lang_handler/src/headers.rs +++ b/crates/lang_handler/src/headers.rs @@ -1,8 +1,12 @@ use std::collections::{hash_map::Entry, HashMap}; +/// Represents a single HTTP header value or multiple values for the same header. #[derive(Debug, Clone)] pub enum Header { + /// A single value for a header. Single(String), + + /// Multiple values for a header, stored as a vector. Multiple(Vec), } @@ -86,7 +90,7 @@ impl Headers { } } - /// Returns all values associated with a header field as a Vec. + /// Returns all values associated with a header field as a `Vec`. /// /// # Examples /// diff --git a/crates/lang_handler/src/lib.rs b/crates/lang_handler/src/lib.rs index 2bdb10d..22bf50b 100644 --- a/crates/lang_handler/src/lib.rs +++ b/crates/lang_handler/src/lib.rs @@ -1,14 +1,202 @@ +#![warn(missing_docs)] + +//! # HTTP Request Management +//! +//! Lang Handler is a library intended for managing HTTP requests between +//! multiple languages. It provides types for representing Headers, Request, +//! and Response, as well as providing a Handler trait for dispatching +//! Request objects into some other system which produces a Response. +//! This may be another language runtime, or it could be a server application +//! directly in Rust. +//! +//! # Building a Request +//! +//! The `Request` type provides a `builder` method which allows you to +//! construct a `Request` object using a fluent API. This allows you to +//! set the URL, HTTP method, headers, body, and other properties of the +//! request in a clear and concise manner. +//! +//! ```rust +//! use lang_handler::{Request, RequestBuilder}; +//! +//! let request = Request::builder() +//! .method("GET") +//! .url("http://example.com") +//! .header("Accept", "application/json") +//! .build() +//! .expect("should build request"); +//! ``` +//! +//! # Reading a Request +//! +//! The `Request` type also provides methods to read the [`Url`], HTTP method, +//! [`Headers`], and body of the request. This allows you to access the +//! properties of the request in a straightforward manner. +//! +//! ```rust +//! # use lang_handler::Request; +//! # +//! # let request = Request::builder() +//! # .method("GET") +//! # .url("http://example.com") +//! # .header("Accept", "application/json") +//! # .build() +//! # .expect("should build request"); +//! # +//! assert_eq!(request.method(), "GET"); +//! assert_eq!(request.url().to_string(), "http://example.com/"); +//! assert_eq!(request.headers().get("Accept"), Some("application/json".to_string())); +//! assert_eq!(request.body(), ""); +//! ``` +//! +//! # Building a Response +//! +//! The `Response` type also provides a `builder` method which allows you to +//! construct a `Response` object using a fluent API. This allows you to +//! set the status code, [`Headers`], body, and other properties of the +//! response in a clear and concise manner. +//! +//! ```rust +//! use lang_handler::{Response, ResponseBuilder}; +//! +//! let response = Response::builder() +//! .status(200) +//! .header("Content-Type", "application/json") +//! .body("{\"message\": \"Hello, world!\"}") +//! .log("This is a log message") +//! .exception("This is an exception message") +//! .build(); +//! ``` +//! +//! # Reading a Response +//! +//! The `Response` type provides methods to read the status code, [`Headers`], +//! body, log, and exception of the response. +//! +//! ```rust +//! # use lang_handler::{Response, ResponseBuilder}; +//! # +//! # let response = Response::builder() +//! # .status(200) +//! # .header("Content-Type", "text/plain") +//! # .body("Hello, World!") +//! # .log("This is a log message") +//! # .exception("This is an exception message") +//! # .build(); +//! # +//! assert_eq!(response.status(), 200); +//! assert_eq!(response.headers().get("Content-Type"), Some("text/plain".to_string())); +//! assert_eq!(response.body(), "Hello, World!"); +//! assert_eq!(response.log(), "This is a log message"); +//! assert_eq!(response.exception(), Some(&"This is an exception message".to_string())); +//! ``` +//! # Managing Headers +//! +//! The `Headers` type provides methods to read and manipulate HTTP headers. +//! +//! ```rust +//! use lang_handler::Headers; +//! +//! // Setting and getting headers +//! let mut headers = Headers::new(); +//! headers.set("Content-Type", "application/json"); +//! assert_eq!(headers.get("Content-Type"), Some("application/json".to_string())); +//! +//! // Checking if a header exists +//! assert!(headers.has("Content-Type")); +//! +//! // Removing headers +//! headers.remove("Content-Type"); +//! assert_eq!(headers.get("Content-Type"), None); +//! +//! // Adding multiple values to a header +//! headers.add("Set-Cookie", "sessionid=abc123"); +//! headers.add("Set-Cookie", "userid=42"); +//! +//! // Iterating over headers +//! for (name, value) in headers.iter() { +//! println!("{}: {:?}", name, value); +//! } +//! +//! // Getting all values for a header +//! let cookies = headers.get_all("Set-Cookie"); +//! assert_eq!(cookies, vec!["sessionid=abc123", "userid=42"]); +//! +//! // Getting a set of headers as a string line +//! headers.add("Accept", "text/plain"); +//! headers.add("Accept", "application/json"); +//! let accept_header = headers.get_line("Accept"); +//! +//! // Counting header lines +//! assert!(headers.len() > 0); +//! +//! // Clearing all headers +//! headers.clear(); +//! +//! // Checking if headers are empty +//! assert!(headers.is_empty()); +//! ``` +//! # Handling Requests +//! +//! The `Handler` trait is used to define how a [`Request`] is handled. It +//! provides a method `handle` which takes a [`Request`] and returns a +//! [`Response`]. This allows you to implement custom logic for handling +//! requests, such as routing them to different services or processing them +//! in some way. +//! +//! ```rust +//! use lang_handler::{ +//! Handler, +//! Request, +//! RequestBuilder, +//! Response, +//! ResponseBuilder +//! }; +//! +//! pub struct EchoServer; +//! impl Handler for EchoServer { +//! type Error = String; +//! fn handle(&self, request: Request) -> Result { +//! let response = Response::builder() +//! .status(200) +//! .body(request.body()) +//! .build(); +//! +//! Ok(response) +//! } +//! } +//! +//! let handler = EchoServer; +//! +//! let request = Request::builder() +//! .method("POST") +//! .url("http://example.com") +//! .header("Accept", "application/json") +//! .body("Hello, world!") +//! .build() +//! .expect("should build request"); +//! +//! let response = handler.handle(request) +//! .expect("should handle request"); +//! +//! assert_eq!(response.status(), 200); +//! assert_eq!(response.body(), "Hello, world!"); +//! ``` + #[cfg(feature = "c")] mod ffi; mod handler; mod headers; mod request; mod response; +pub mod rewrite; +mod test; #[cfg(feature = "c")] pub use ffi::*; pub use handler::Handler; pub use headers::{Header, Headers}; -pub use request::{Request, RequestBuilder}; +pub use request::{Request, RequestBuilder, RequestBuilderException}; pub use response::{Response, ResponseBuilder}; +pub use test::{MockRoot, MockRootBuilder}; pub use url::Url; diff --git a/crates/lang_handler/src/request.rs b/crates/lang_handler/src/request.rs index 94c59e6..34ffffd 100644 --- a/crates/lang_handler/src/request.rs +++ b/crates/lang_handler/src/request.rs @@ -1,12 +1,9 @@ -use std::{ - fmt::Debug, - net::{AddrParseError, SocketAddr}, -}; +use std::{fmt::Debug, net::SocketAddr}; use bytes::{Bytes, BytesMut}; -use url::{ParseError, Url}; +use url::Url; -use crate::Headers; +use super::Headers; /// Represents an HTTP request. Includes the method, URL, headers, and body. /// @@ -17,7 +14,7 @@ use crate::Headers; /// /// let request = Request::builder() /// .method("POST") -/// .url("http://example.com/test.php").expect("invalid url") +/// .url("http://example.com/test.php") /// .header("Accept", "text/html") /// .header("Accept", "application/json") /// .header("Host", "example.com") @@ -94,7 +91,7 @@ impl Request { /// /// let request = Request::builder() /// .method("POST") - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .header("Content-Type", "text/html") /// .header("Content-Length", 13.to_string()) /// .body("Hello, World!") @@ -120,7 +117,7 @@ impl Request { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .header("Content-Type", "text/plain") /// .build() /// .expect("should build request"); @@ -279,16 +276,24 @@ impl Request { } /// Errors which may be produced when building a Request from a RequestBuilder. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq, Hash)] pub enum RequestBuilderException { /// Url is required - MissingUrl, + UrlMissing, + /// Url could not be parsed + UrlParseFailed(String), + /// SocketAddr could not be parsed + SocketParseFailed(String), } impl std::fmt::Display for RequestBuilderException { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - RequestBuilderException::MissingUrl => write!(f, "Expected url to be set"), + RequestBuilderException::UrlMissing => write!(f, "Expected url to be set"), + RequestBuilderException::UrlParseFailed(u) => write!(f, "Failed to parse url: \"{}\"", u), + RequestBuilderException::SocketParseFailed(s) => { + write!(f, "Failed to parse socket info: \"{}\"", s) + } } } } @@ -302,7 +307,7 @@ impl std::fmt::Display for RequestBuilderException { /// /// let request = Request::builder() /// .method("POST") -/// .url("http://example.com/test.php").expect("invalid url") +/// .url("http://example.com/test.php") /// .header("Content-Type", "text/html") /// .header("Content-Length", 13.to_string()) /// .body("Hello, World!") @@ -318,11 +323,11 @@ impl std::fmt::Display for RequestBuilderException { #[derive(Clone)] pub struct RequestBuilder { method: Option, - url: Option, + url: Option, headers: Headers, body: BytesMut, - local_socket: Option, - remote_socket: Option, + local_socket: Option, + remote_socket: Option, } impl RequestBuilder { @@ -377,11 +382,11 @@ impl RequestBuilder { pub fn extend(request: &Request) -> Self { Self { method: Some(request.method().into()), - url: Some(request.url().clone()), + url: Some(request.url().to_string()), headers: request.headers().clone(), body: BytesMut::from(request.body()), - local_socket: request.local_socket, - remote_socket: request.remote_socket, + local_socket: request.local_socket.map(|s| s.to_string()), + remote_socket: request.remote_socket.map(|s| s.to_string()), } } @@ -394,7 +399,7 @@ impl RequestBuilder { /// /// let request = RequestBuilder::new() /// .method("POST") - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .build() /// .expect("should build request"); /// @@ -413,23 +418,18 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .build() /// .expect("should build request"); /// /// assert_eq!(request.url().as_str(), "http://example.com/test.php"); /// ``` - pub fn url(mut self, url: T) -> Result + pub fn url(mut self, url: T) -> Self where T: Into, { - match url.into().parse() { - Ok(url) => { - self.url = Some(url); - Ok(self) - } - Err(e) => Err(e), - } + self.url = Some(url.into()); + self } /// Sets a header of the request. @@ -440,7 +440,7 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .header("Accept", "text/html") /// .build() /// .expect("should build request"); @@ -464,7 +464,7 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .body("Hello, World!") /// .build() /// .expect("should build request"); @@ -485,8 +485,8 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") - /// .local_socket("127.0.0.1:8080").expect("invalid local socket") + /// .url("http://example.com/test.php") + /// .local_socket("127.0.0.1:8080") /// .build() /// .expect("should build request"); /// @@ -495,17 +495,12 @@ impl RequestBuilder { /// .expect("should parse"); /// assert_eq!(request.local_socket(), Some(expected)); /// ``` - pub fn local_socket(mut self, local_socket: T) -> Result + pub fn local_socket(mut self, local_socket: T) -> Self where T: Into, { - match local_socket.into().parse() { - Err(e) => Err(e), - Ok(local_socket) => { - self.local_socket = Some(local_socket); - Ok(self) - } - } + self.local_socket = Some(local_socket.into()); + self } /// Sets the remote socket of the request. @@ -517,8 +512,8 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") - /// .remote_socket("127.0.0.1:8080").expect("invalid remote socket") + /// .url("http://example.com/test.php") + /// .remote_socket("127.0.0.1:8080") /// .build() /// .expect("should build request"); /// @@ -527,17 +522,12 @@ impl RequestBuilder { /// .expect("should parse"); /// assert_eq!(request.remote_socket(), Some(expected)); /// ``` - pub fn remote_socket(mut self, remote_socket: T) -> Result + pub fn remote_socket(mut self, remote_socket: T) -> Self where T: Into, { - match remote_socket.into().parse() { - Err(e) => Err(e), - Ok(remote_socket) => { - self.remote_socket = Some(remote_socket); - Ok(self) - } - } + self.remote_socket = Some(remote_socket.into()); + self } /// Builds the request. @@ -548,7 +538,7 @@ impl RequestBuilder { /// use lang_handler::RequestBuilder; /// /// let request = RequestBuilder::new() - /// .url("http://example.com/test.php").expect("invalid url") + /// .url("http://example.com/test.php") /// .build() /// .expect("should build request"); /// @@ -559,11 +549,11 @@ impl RequestBuilder { pub fn build(self) -> Result { Ok(Request { method: self.method.unwrap_or_else(|| "GET".to_string()), - url: self.url.ok_or(RequestBuilderException::MissingUrl)?, + url: parse_url(self.url)?, headers: self.headers, body: self.body.freeze(), - local_socket: self.local_socket, - remote_socket: self.remote_socket, + local_socket: parse_socket(self.local_socket)?, + remote_socket: parse_socket(self.remote_socket)?, }) } } @@ -573,3 +563,23 @@ impl Default for RequestBuilder { Self::new() } } + +fn parse_url(url: Option) -> Result { + url + .ok_or(RequestBuilderException::UrlMissing) + .and_then(|u| { + u.parse() + .map_err(|_| RequestBuilderException::UrlParseFailed(u)) + }) +} + +fn parse_socket(socket: Option) -> Result, RequestBuilderException> { + socket.map_or_else( + || Ok(None), + |s| { + Ok(Some(s.parse::().map_err(|_| { + RequestBuilderException::SocketParseFailed(s) + })?)) + }, + ) +} diff --git a/crates/lang_handler/src/response.rs b/crates/lang_handler/src/response.rs index c2c4963..4d3faa5 100644 --- a/crates/lang_handler/src/response.rs +++ b/crates/lang_handler/src/response.rs @@ -1,14 +1,13 @@ use bytes::{Bytes, BytesMut}; -use crate::Headers; +use super::Headers; /// Represents an HTTP response. This includes the status code, headers, body, log, and exception. /// /// # Example /// /// ``` -/// use lang_handler::{Response, ResponseBuilder}; -/// +/// # use lang_handler::{Response, ResponseBuilder}; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -35,8 +34,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::{Response, Headers}; - /// + /// # use lang_handler::{Response, Headers}; /// let mut headers = Headers::new(); /// headers.set("Content-Type", "text/plain"); /// @@ -73,8 +71,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::Response; - /// + /// # use lang_handler::Response; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -94,8 +91,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::{Response, ResponseBuilder}; - /// + /// # use lang_handler::{Response, ResponseBuilder}; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -119,8 +115,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::Response; - /// + /// # use lang_handler::Response; /// let response = Response::builder() /// .status(200) /// .build(); @@ -131,13 +126,12 @@ impl Response { self.status } - /// Returns the headers of the response. + /// Returns the [`Headers`] of the response. /// /// # Example /// /// ``` - /// use lang_handler::{Response, Headers}; - /// + /// # use lang_handler::{Response, Headers}; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -154,8 +148,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::Response; - /// + /// # use lang_handler::Response; /// let response = Response::builder() /// .status(200) /// .body("Hello, World!") @@ -172,8 +165,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::Response; - /// + /// # use lang_handler::Response; /// let response = Response::builder() /// .status(200) /// .log("log") @@ -190,8 +182,7 @@ impl Response { /// # Example /// /// ``` - /// use lang_handler::Response; - /// + /// # use lang_handler::Response; /// let response = Response::builder() /// .status(200) /// .exception("exception") @@ -209,8 +200,7 @@ impl Response { /// # Example /// /// ``` -/// use lang_handler::{Response, ResponseBuilder}; -/// +/// # use lang_handler::{Response, ResponseBuilder}; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -236,8 +226,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let builder = ResponseBuilder::new(); /// ``` pub fn new() -> Self { @@ -250,13 +239,36 @@ impl ResponseBuilder { } } - /// Creates a new response builder that extends the given response. + /// Builds the response. /// /// # Example /// /// ``` - /// use lang_handler::{Response, ResponseBuilder}; + /// # use lang_handler::ResponseBuilder; + /// let response = ResponseBuilder::new() + /// .build(); /// + /// assert_eq!(response.status(), 200); + /// assert_eq!(response.body(), ""); + /// assert_eq!(response.log(), ""); + /// assert_eq!(response.exception(), None); + /// ``` + pub fn build(&self) -> Response { + Response { + status: self.status.unwrap_or(200), + headers: self.headers.clone(), + body: self.body.clone().freeze(), + log: self.log.clone().freeze(), + exception: self.exception.clone(), + } + } + + /// Creates a new response builder that extends the given response. + /// + /// # Example + /// + /// ``` + /// # use lang_handler::{Response, ResponseBuilder}; /// let response = Response::builder() /// .status(200) /// .header("Content-Type", "text/plain") @@ -286,8 +298,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let response = ResponseBuilder::new() /// .status(300) /// .build(); @@ -304,8 +315,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let response = ResponseBuilder::new() /// .header("Content-Type", "text/plain") /// .build(); @@ -326,8 +336,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let builder = ResponseBuilder::new() /// .body("Hello, World!") /// .build(); @@ -339,6 +348,20 @@ impl ResponseBuilder { self } + /// Appends to the body of the response. + /// + /// # Example + /// + /// ``` + /// use lang_handler::ResponseBuilder; + /// + /// let response = ResponseBuilder::new() + /// .body("Hello, ") + /// .body_write("World!") + /// .build(); + /// + /// assert_eq!(response.body(), "Hello, World!"); + /// ``` pub fn body_write>(&mut self, body: B) -> &mut Self { self.body.extend_from_slice(&body.into()); self @@ -349,8 +372,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let builder = ResponseBuilder::new() /// .log("log") /// .build(); @@ -362,6 +384,19 @@ impl ResponseBuilder { self } + /// Appends to the log of the response. + /// + /// # Example + /// + /// ``` + /// # use lang_handler::ResponseBuilder; + /// let builder = ResponseBuilder::new() + /// .log_write("logs") + /// .log_write("more logs") + /// .build(); + /// + /// assert_eq!(builder.log(), "logs\nmore logs\n"); + /// ``` pub fn log_write>(&mut self, log: L) -> &mut Self { self.log.extend_from_slice(&log.into()); self.log.extend_from_slice(b"\n"); @@ -373,8 +408,7 @@ impl ResponseBuilder { /// # Example /// /// ``` - /// use lang_handler::ResponseBuilder; - /// + /// # use lang_handler::ResponseBuilder; /// let builder = ResponseBuilder::new() /// .exception("exception") /// .build(); @@ -385,31 +419,6 @@ impl ResponseBuilder { self.exception = Some(exception.into()); self } - - /// Builds the response. - /// - /// # Example - /// - /// ``` - /// use lang_handler::ResponseBuilder; - /// - /// let response = ResponseBuilder::new() - /// .build(); - /// - /// assert_eq!(response.status(), 200); - /// assert_eq!(response.body(), ""); - /// assert_eq!(response.log(), ""); - /// assert_eq!(response.exception(), None); - /// ``` - pub fn build(&self) -> Response { - Response { - status: self.status.unwrap_or(200), - headers: self.headers.clone(), - body: self.body.clone().freeze(), - log: self.log.clone().freeze(), - exception: self.exception.clone(), - } - } } impl Default for ResponseBuilder { diff --git a/crates/lang_handler/src/rewrite/condition/closure.rs b/crates/lang_handler/src/rewrite/condition/closure.rs new file mode 100644 index 0000000..9ebf5c8 --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/closure.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use super::{Condition, Request}; + +impl Condition for F +where + F: Fn(&Request, &Path) -> bool + Sync + Send, +{ + /// Matches if calling the Fn(&Request) with the given request returns true + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # use lang_handler::{Request, rewrite::Condition}; + /// # let docroot = std::env::temp_dir(); + /// let condition = |request: &Request, _docroot: &Path| -> bool { + /// request.url().path().contains("/foo") + /// }; + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("request should build"); + /// + /// assert!(!condition.matches(&request, &docroot)); + /// ``` + fn matches(&self, request: &Request, docroot: &Path) -> bool { + self(request, docroot) + } +} diff --git a/crates/lang_handler/src/rewrite/condition/existence.rs b/crates/lang_handler/src/rewrite/condition/existence.rs new file mode 100644 index 0000000..8513e42 --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/existence.rs @@ -0,0 +1,96 @@ +use std::path::Path; + +use super::Condition; +use super::Request; + +/// Match if request path exists +#[derive(Clone, Debug, Default)] +pub struct ExistenceCondition; + +impl Condition for ExistenceCondition { + /// An ExistenceCondition matches a request if the path segment of the + /// request url exists in the provided base directory. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # rewrite::{Condition, ExistenceCondition}, + /// # Request, + /// # MockRoot + /// # }; + /// # + /// # let docroot = MockRoot::builder() + /// # .file("exists.php", "") + /// # .build() + /// # .expect("should prepare docroot"); + /// let condition = ExistenceCondition; + /// + /// let request = Request::builder() + /// .url("http://example.com/exists.php") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!condition.matches( + /// # &request.extend() + /// # .url("http://example.com/does_not_exist.php") + /// # .build() + /// # .expect("should build request"), + /// # &docroot + /// # )); + /// ``` + fn matches(&self, request: &Request, docroot: &Path) -> bool { + let path = request.url().path(); + docroot + .join(path.strip_prefix("/").unwrap_or(path)) + .canonicalize() + .is_ok() + } +} + +/// Match if request path does not exist +#[derive(Clone, Debug, Default)] +pub struct NonExistenceCondition; + +impl Condition for NonExistenceCondition { + /// A NonExistenceCondition matches a request if the path segment of the + /// request url does not exist in the provided base directory. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # rewrite::{Condition, NonExistenceCondition}, + /// # Request, + /// # MockRoot + /// # }; + /// # + /// # let docroot = MockRoot::builder() + /// # .file("exists.php", "") + /// # .build() + /// # .expect("should prepare docroot"); + /// let condition = NonExistenceCondition; + /// + /// let request = Request::builder() + /// .url("http://example.com/does_not_exist.php") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!condition.matches( + /// # &request.extend() + /// # .url("http://example.com/exists.php") + /// # .build() + /// # .expect("should build request"), + /// # &docroot + /// # )); + /// ``` + fn matches(&self, request: &Request, docroot: &Path) -> bool { + let path = request.url().path(); + docroot + .join(path.strip_prefix("/").unwrap_or(path)) + .canonicalize() + .is_err() + } +} diff --git a/crates/lang_handler/src/rewrite/condition/group.rs b/crates/lang_handler/src/rewrite/condition/group.rs new file mode 100644 index 0000000..ea2804b --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/group.rs @@ -0,0 +1,121 @@ +use std::path::Path; + +use super::{Condition, Request}; + +// Tested via Condition::and(...) and Condition::or(...) doctests + +/// This provides logical grouping of conditions using either AND or OR +/// combination behaviours. +pub enum ConditionGroup +where + A: Condition + ?Sized, + B: Condition + ?Sized, +{ + /// Combines two conditions using logical OR. + Or(Box, Box), + + /// Combines two conditions using logical AND. + And(Box, Box), +} + +impl ConditionGroup +where + A: Condition + ?Sized, + B: Condition + ?Sized, +{ + /// Constructs a new ConditionGroup with the given conditions combined using + /// logical AND. + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # use lang_handler::{Request, rewrite::{Condition, ConditionGroup}}; + /// # let docroot = std::env::temp_dir(); + /// let condition = ConditionGroup::and( + /// Box::new(|_req: &Request, _docroot: &Path| true), + /// Box::new(|_req: &Request, _docroot: &Path| false), + /// ); + /// + /// let request = Request::builder() + /// .url("http://example.com") + /// .build() + /// .expect("should build request"); + /// + /// assert!(!condition.matches(&request, &docroot)); + /// # + /// # assert!(ConditionGroup::and( + /// # Box::new(|_req: &Request, _docroot: &Path| true), + /// # Box::new(|_req: &Request, _docroot: &Path| true), + /// # ).matches(&request, &docroot)); + /// ``` + pub fn and(a: Box, b: Box) -> Box { + Box::new(ConditionGroup::And(a, b)) + } + + /// Constructs a new ConditionGroup with the given conditions combined using + /// logical OR. + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # use lang_handler::{Request, rewrite::{Condition, ConditionGroup}}; + /// # let docroot = std::env::temp_dir(); + /// let condition = ConditionGroup::or( + /// Box::new(|_req: &Request, _docroot: &Path| true), + /// Box::new(|_req: &Request, _docroot: &Path| false), + /// ); + /// + /// let request = Request::builder() + /// .url("http://example.com") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # + /// # assert!(!ConditionGroup::or( + /// # Box::new(|_req: &Request, _docroot: &Path| false), + /// # Box::new(|_req: &Request, _docroot: &Path| false), + /// # ).matches(&request, &docroot)); + pub fn or(a: Box, b: Box) -> Box { + Box::new(ConditionGroup::Or(a, b)) + } +} + +impl Condition for ConditionGroup +where + A: Condition + ?Sized, + B: Condition + ?Sized, +{ + /// Evaluates the condition group against the provided request. + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # let docroot = std::env::temp_dir(); + /// # use lang_handler::{Request, rewrite::{Condition, ConditionGroup}}; + /// let condition = ConditionGroup::or( + /// Box::new(|_req: &Request, _docroot: &Path| true), + /// Box::new(|_req: &Request, _docroot: &Path| false), + /// ); + /// + /// let request = Request::builder() + /// .url("http://example.com") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!ConditionGroup::or( + /// # Box::new(|_req: &Request, _docroot: &Path| false), + /// # Box::new(|_req: &Request, _docroot: &Path| false), + /// # ).matches(&request, &docroot)); + /// ``` + fn matches(&self, request: &Request, docroot: &Path) -> bool { + match self { + ConditionGroup::Or(a, b) => a.matches(request, docroot) || b.matches(request, docroot), + ConditionGroup::And(a, b) => a.matches(request, docroot) && b.matches(request, docroot), + } + } +} diff --git a/crates/lang_handler/src/rewrite/condition/header.rs b/crates/lang_handler/src/rewrite/condition/header.rs new file mode 100644 index 0000000..41c5794 --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/header.rs @@ -0,0 +1,74 @@ +use std::{fmt::Debug, path::Path}; + +use regex::{Error, Regex}; + +use super::Condition; +use crate::Request; + +/// Matches a request header to a regex pattern +#[derive(Clone, Debug)] +pub struct HeaderCondition { + name: String, + pattern: Regex, +} + +impl HeaderCondition { + /// Construct a new HeaderCondition matching the given header name and Regex + /// pattern. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Condition, HeaderCondition}; + /// # use lang_handler::Request; + /// let condition = HeaderCondition::new("TEST", "^foo$") + /// .expect("should be valid regex"); + /// ``` + pub fn new(name: S, pattern: R) -> Result, Error> + where + S: Into, + R: TryInto, + Error: From<>::Error>, + { + let name = name.into(); + let pattern = pattern.try_into()?; + Ok(Box::new(Self { name, pattern })) + } +} + +impl Condition for HeaderCondition { + /// A HeaderCondition matches a given request if the header specified in the + /// constructor is both present and matches the given Regex pattern. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Condition, HeaderCondition}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let condition = HeaderCondition::new("TEST", "^foo$") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .header("TEST", "foo") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!condition.matches( + /// # &request.extend() + /// # .header("TEST", "bar") + /// # .build() + /// # .expect("should build request"), + /// # &docroot + /// # )); + /// ``` + fn matches(&self, request: &Request, _docroot: &Path) -> bool { + request + .headers() + .get_line(&self.name) + .map(|line| self.pattern.is_match(&line)) + .unwrap_or(false) + } +} diff --git a/crates/lang_handler/src/rewrite/condition/method.rs b/crates/lang_handler/src/rewrite/condition/method.rs new file mode 100644 index 0000000..7783a3f --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/method.rs @@ -0,0 +1,65 @@ +use std::{fmt::Debug, path::Path}; + +use regex::{Error, Regex}; + +use super::Condition; +use crate::Request; + +/// Matches a request method to a regex pattern +#[derive(Clone, Debug)] +pub struct MethodCondition(Regex); + +impl MethodCondition { + /// Construct a new MethodCondition matching the Request method to the given + /// Regex pattern. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Condition, MethodCondition}; + /// # use lang_handler::Request; + /// let condition = MethodCondition::new("GET") + /// .expect("should be valid regex"); + /// ``` + pub fn new(pattern: R) -> Result, Error> + where + R: TryInto, + Error: From<>::Error>, + { + let pattern = pattern.try_into()?; + Ok(Box::new(Self(pattern))) + } +} + +impl Condition for MethodCondition { + /// A MethodCondition matches a given request if the Request method matches + /// the given Regex pattern. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Condition, MethodCondition}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let condition = MethodCondition::new("GET") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .method("GET") + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!condition.matches( + /// # &request.extend() + /// # .method("POST") + /// # .build() + /// # .expect("should build request"), + /// # &docroot + /// # )); + /// ``` + fn matches(&self, request: &Request, _docroot: &Path) -> bool { + self.0.is_match(request.method()) + } +} diff --git a/crates/lang_handler/src/rewrite/condition/mod.rs b/crates/lang_handler/src/rewrite/condition/mod.rs new file mode 100644 index 0000000..e9561c8 --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/mod.rs @@ -0,0 +1,126 @@ +mod closure; +mod existence; +mod group; +mod header; +mod method; +mod path; + +use std::path::Path; + +use crate::Request; + +pub use existence::{ExistenceCondition, NonExistenceCondition}; +pub use group::ConditionGroup; +pub use header::HeaderCondition; +pub use method::MethodCondition; +pub use path::PathCondition; + +/// A Condition is used to match against request state before deciding to apply +/// a given Rewrite or set of Rewrites. +pub trait Condition: Sync + Send { + /// A Condition must implement a `matches(request) -> bool` method which + /// receives a request object to determine if the condition is met. + fn matches(&self, request: &Request, docroot: &Path) -> bool; +} + +impl ConditionExt for T where T: Condition {} + +/// Extends Condition with combinators like `and` and `or`. +pub trait ConditionExt: Condition { + /// Make a new condition which must pass both conditions + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{Condition, ConditionExt, PathCondition, HeaderCondition} + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let path = PathCondition::new("^/index.php$") + /// .expect("should be valid regex"); + /// + /// let header = HeaderCondition::new("TEST", "^foo$") + /// .expect("should be valid regex"); + /// + /// let condition = path.and(header); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .header("TEST", "foo") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # + /// # // SHould _not_ match if either condition does not match + /// # let only_header = Request::builder() + /// # .url("http://example.com/nope.php") + /// # .header("TEST", "foo") + /// # .build() + /// # .expect("request should build"); + /// # + /// # assert!(!condition.matches(&only_header, &docroot)); + /// # + /// # let only_url = Request::builder() + /// # .url("http://example.com/index.php") + /// # .build() + /// # .expect("request should build"); + /// # + /// # assert!(!condition.matches(&only_url, &docroot)); + /// ``` + fn and(self: Box, other: Box) -> Box> + where + C: Condition + ?Sized, + { + ConditionGroup::and(self, other) + } + + /// Make a new condition which must pass either condition + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{Condition, ConditionExt, PathCondition, HeaderCondition} + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let path = PathCondition::new("^/index.php$") + /// .expect("should be valid regex"); + /// + /// let header = HeaderCondition::new("TEST", "^foo$") + /// .expect("should be valid regex"); + /// + /// let condition = path.or(header); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # + /// # // Should match if one condition does not + /// # let only_header = Request::builder() + /// # .url("http://example.com/nope.php") + /// # .header("TEST", "foo") + /// # .build() + /// # .expect("request should build"); + /// # + /// # assert!(condition.matches(&only_header, &docroot)); + /// # + /// # let only_url = Request::builder() + /// # .url("http://example.com/index.php") + /// # .build() + /// # .expect("request should build"); + /// # + /// # assert!(condition.matches(&only_url, &docroot)); + /// ``` + fn or(self: Box, other: Box) -> Box> + where + C: Condition + ?Sized, + { + ConditionGroup::or(self, other) + } +} diff --git a/crates/lang_handler/src/rewrite/condition/path.rs b/crates/lang_handler/src/rewrite/condition/path.rs new file mode 100644 index 0000000..cfec01c --- /dev/null +++ b/crates/lang_handler/src/rewrite/condition/path.rs @@ -0,0 +1,64 @@ +use std::{fmt::Debug, path::Path}; + +use regex::{Error, Regex}; + +use super::Condition; +use super::Request; + +/// Match request path to a regex pattern +#[derive(Clone, Debug)] +pub struct PathCondition { + pattern: Regex, +} + +impl PathCondition { + /// Construct a new PathCondition matching the given Regex pattern. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::PathCondition; + /// let condition = PathCondition::new("^/index.php$") + /// .expect("should be valid regex"); + /// ``` + pub fn new(pattern: R) -> Result, Error> + where + R: TryInto, + Error: From<>::Error>, + { + let pattern = pattern.try_into()?; + Ok(Box::new(Self { pattern })) + } +} + +impl Condition for PathCondition { + /// A PathCondition matches a request if the path segment of the request url + /// matches the pattern given when constructing the PathCondition. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Condition, PathCondition}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let condition = PathCondition::new("^/index.php$") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// assert!(condition.matches(&request, &docroot)); + /// # assert!(!condition.matches( + /// # &request.extend() + /// # .url("http://example.com/other.php") + /// # .build() + /// # .expect("should build request"), + /// # &docroot + /// # )); + /// ``` + fn matches(&self, request: &Request, _docroot: &Path) -> bool { + self.pattern.is_match(request.url().path()) + } +} diff --git a/crates/lang_handler/src/rewrite/conditional_rewriter.rs b/crates/lang_handler/src/rewrite/conditional_rewriter.rs new file mode 100644 index 0000000..24a369e --- /dev/null +++ b/crates/lang_handler/src/rewrite/conditional_rewriter.rs @@ -0,0 +1,107 @@ +use std::path::Path; + +use crate::{ + rewrite::{Condition, Rewriter}, + Request, RequestBuilderException, +}; + +// Tested via Rewriter::when(...) doc-test + +/// This provides a rewriter that applies another rewriter conditionally based +/// on a condition. +pub struct ConditionalRewriter(Box, Box) +where + R: Rewriter + ?Sized, + C: Condition + ?Sized; + +impl ConditionalRewriter +where + R: Rewriter + ?Sized, + C: Condition + ?Sized, +{ + /// Constructs a new ConditionalRewriter with the given rewriter and + /// condition. The rewriter will only be applied if the condition matches + /// the request. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::rewrite::{ + /// # Rewriter, + /// # ConditionalRewriter, + /// # PathCondition, + /// # PathRewriter + /// # }; + /// let condition = PathCondition::new("^/index\\.php$") + /// .expect("should be valid regex"); + /// + /// let rewriter = PathRewriter::new("^(.*)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let conditional_rewriter = + /// ConditionalRewriter::new(rewriter, condition); + /// ``` + pub fn new(rewriter: Box, condition: Box) -> Box { + Box::new(Self(rewriter, condition)) + } +} + +impl Rewriter for ConditionalRewriter +where + R: Rewriter + ?Sized, + C: Condition + ?Sized, +{ + /// A ConditionalRewriter matches a request if its condition matches the + /// request. If it does, the rewriter is applied to the request. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{ + /// # Condition, + /// # ConditionalRewriter, + /// # PathCondition, + /// # PathRewriter, + /// # Rewriter + /// # } + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let condition = PathCondition::new("^/index\\.php$") + /// .expect("should be valid regex"); + /// + /// let rewriter = PathRewriter::new("^(.*)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let conditional_rewriter = + /// ConditionalRewriter::new(rewriter, condition); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = conditional_rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/foo/index.php".to_string()); + /// # + /// # let request = Request::builder() + /// # .url("http://example.com/other.php") + /// # .build() + /// # .expect("should build request"); + /// # + /// # let new_request = conditional_rewriter.rewrite(request, &docroot) + /// # .expect("should rewrite request"); + /// # + /// # assert_eq!(new_request.url().path(), "/other.php".to_string()); + /// ``` + fn rewrite(&self, request: Request, docroot: &Path) -> Result { + if !self.1.matches(&request, docroot) { + return Ok(request); + } + + self.0.rewrite(request, docroot) + } +} diff --git a/crates/lang_handler/src/rewrite/mod.rs b/crates/lang_handler/src/rewrite/mod.rs new file mode 100644 index 0000000..9d95aa7 --- /dev/null +++ b/crates/lang_handler/src/rewrite/mod.rs @@ -0,0 +1,121 @@ +//! # Request Rewriting +//! +//! There are two sets of tools to manage request rewriting: +//! +//! - A [`Condition`] matches existing Request state by some given criteria. +//! - A [`Rewriter`] applies replacement logic to produce new Request state. +//! +//! # Conditions +//! +//! There are several types of [`Condition`] for matching Request state: +//! +//! - [`HeaderCondition`] matches if named header matches the given pattern. +//! - [`PathCondition`] matches if Request path matches the given pattern. +//! - [`ExistenceCondition`] matches if Request path resolves to a real file. +//! - [`NonExistenceCondition`] matches if Request path does not resolve. +//! +//! In addition to these core types, any function with a `Fn(&Request) -> bool` +//! signature may also be used anywhere a [`Condition`] is expected. This +//! allows any arbitrary logic to be applied to decide a match. Because a +//! Request may be dispatched to any thread, these functions must be +//! `Send + Sync`. +//! +//! ``` +//! # use lang_handler::{Request, rewrite::Condition}; +//! let condition = |request: &Request| -> bool { +//! request.url().path().starts_with("/foo") +//! }; +//! ``` +//! +//! Multiple [Condition] types may be grouped together to form logical +//! conditions using `condition.and(other)` or `condition.or(other)` to apply +//! conditions with AND or OR logic respectively. +//! +//! # Rewriters +//! +//! There are several types of [`Rewriter`] for rewriting Request state: +//! +//! - [`HeaderRewriter`] rewrites named header using pattern and replacement. +//! - [`PathRewriter`] rewrites Request path using pattern and replacement. +//! +//! As with [`Condition`], any function with a `Fn(Request) -> Request` +//! signature may also be used anywhere a [`Rewriter`] is accepted. This allows +//! any custom logic to be used to produce a rewritten Request. Because a +//! Request may be dispatched to any thread, these functions must be +//! `Send + Sync`. +//! +//! ``` +//! # use lang_handler::{Request, RequestBuilderException, rewrite::Rewriter}; +//! let rewriter = |request: Request| -> Result { +//! request.extend() +//! .url("http://example.com/rewritten") +//! .build() +//! }; +//! ``` +//! +//! Multiple Rewriters may be sequenced using `rewriter.then(other)` to apply +//! in order. +//! +//! # Combining Conditions and Rewriters +//! +//! Rewriters on their own _always_ apply, but this is generally not desirable +//! so Conditions exist to switch their application on or off. This is done +//! using `rewriter.when(condition)` to apply a [`Rewriter`] only when the given +//! [`Condition`] matches. +//! +//! # Complex sequencing +//! +//! Using the condition grouping and rewriter sequencing combinators, one can +//! achieve some quite complex rewriting logic. +//! +//! ```rust +//! # use lang_handler::rewrite::{ +//! # Condition, +//! # ConditionExt, +//! # HeaderCondition, +//! # PathCondition, +//! # Rewriter, +//! # RewriterExt, +//! # PathRewriter +//! # }; +//! # +//! let admin = { +//! let is_admin_path = PathCondition::new("^/admin") +//! .expect("regex is valid"); +//! +//! let is_admin_header = HeaderCondition::new("ADMIN_PASSWORD", "not-very-secure") +//! .expect("regex is valid"); +//! +//! let is_bypass = HeaderCondition::new("DEV_BYPASS", "do-not-use-this") +//! .expect("regex is valid"); +//! +//! let admin_conditions = is_admin_path +//! .and(is_admin_header) +//! .or(is_bypass); +//! +//! let admin_rewrite = PathRewriter::new("^(/admin)", "/secret") +//! .expect("regex is valid"); +//! +//! admin_rewrite.when(admin_conditions) +//! }; +//! +//! let login = { +//! let condition = PathCondition::new("^/login$") +//! .expect("regex is valid"); +//! +//! let rewriter = PathRewriter::new(".*", "/auth") +//! .expect("regex is valid"); +//! +//! rewriter.when(condition) +//! }; +//! +//! let rewrite_rules = admin.then(login); +//! ``` + +mod condition; +mod conditional_rewriter; +mod rewriter; + +pub use condition::*; +pub use conditional_rewriter::ConditionalRewriter; +pub use rewriter::*; diff --git a/crates/lang_handler/src/rewrite/rewriter/closure.rs b/crates/lang_handler/src/rewrite/rewriter/closure.rs new file mode 100644 index 0000000..f0e6f3f --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/closure.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use super::{Request, RequestBuilderException, Rewriter}; + +impl Rewriter for F +where + F: Fn(Request, &Path) -> Result + Sync + Send, +{ + /// Rewrites the request by calling the Fn(&Request) with the given request + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # use lang_handler::{Request, rewrite::Rewriter}; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = |request: Request, docroot: &Path| { + /// request.extend() + /// .url("http://example.com/foo/bar") + /// .build() + /// }; + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("request should build"); + /// + /// let new_request = rewriter.rewrite(request, &docroot) + /// .expect("rewriting should succeed"); + /// + /// assert_eq!(new_request.url().path(), "/foo/bar".to_string()); + /// ``` + fn rewrite(&self, request: Request, docroot: &Path) -> Result { + self(request, docroot) + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/header.rs b/crates/lang_handler/src/rewrite/rewriter/header.rs new file mode 100644 index 0000000..83d42e3 --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/header.rs @@ -0,0 +1,85 @@ +use std::path::Path; + +use regex::{Error, Regex}; + +use super::{Request, RequestBuilderException, Rewriter}; + +/// Rewrite a request header using a given pattern and replacement. +pub struct HeaderRewriter { + name: String, + pattern: Regex, + replacement: String, +} + +impl HeaderRewriter { + /// Construct a new HeaderRewriter to replace the named header using the + /// provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, HeaderRewriter}; + /// # use lang_handler::Request; + /// let rewriter = HeaderRewriter::new("TEST", "(foo)", "$1bar") + /// .expect("should be valid regex"); + /// ``` + pub fn new(name: N, pattern: R, replacement: S) -> Result, Error> + where + N: Into, + R: TryInto, + Error: From<>::Error>, + S: Into, + { + let name = name.into(); + let pattern = pattern.try_into()?; + let replacement = replacement.into(); + Ok(Box::new(Self { + name, + pattern, + replacement, + })) + } +} + +impl Rewriter for HeaderRewriter { + /// Rewrite named header using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, HeaderRewriter}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = HeaderRewriter::new("TEST", "(foo)", "${1}bar") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .header("TEST", "foo") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!( + /// new_request.headers().get("TEST"), + /// Some("foobar".to_string()) + /// ); + /// ``` + fn rewrite(&self, request: Request, _docroot: &Path) -> Result { + let HeaderRewriter { + name, + pattern, + replacement, + } = self; + + match request.headers().get(name) { + None => Ok(request), + Some(value) => request + .extend() + .header(name, pattern.replace(&value, replacement.clone())) + .build(), + } + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/href.rs b/crates/lang_handler/src/rewrite/rewriter/href.rs new file mode 100644 index 0000000..f7f92da --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/href.rs @@ -0,0 +1,86 @@ +use std::path::Path; + +use regex::{Error, Regex}; +use url::Url; + +use super::{Request, RequestBuilderException, Rewriter}; + +/// Rewrite a request href using a given pattern and replacement. +pub struct HrefRewriter(Regex, String); + +impl HrefRewriter { + /// Construct HrefRewriter using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, HrefRewriter}; + /// # use lang_handler::Request; + /// let rewriter = HrefRewriter::new("^(/foo)$", "/index.php") + /// .expect("should be valid regex"); + /// ``` + pub fn new(pattern: R, replacement: S) -> Result, Error> + where + R: TryInto, + Error: From<>::Error>, + S: Into, + { + let pattern = pattern.try_into()?; + let replacement = replacement.into(); + Ok(Box::new(Self(pattern, replacement))) + } +} + +impl Rewriter for HrefRewriter { + /// Rewrite request path using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, HrefRewriter}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = HrefRewriter::new("^(.*)$", "/index.php?route=$1") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .url("http://example.com/foo/bar") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/index.php".to_string()); + /// assert_eq!(new_request.url().query(), Some("route=/foo/bar")); + /// ``` + fn rewrite(&self, request: Request, _docroot: &Path) -> Result { + let HrefRewriter(pattern, replacement) = self; + let url = request.url(); + + let input = { + let path = url.path(); + let query = url.query().map_or(String::new(), |q| format!("?{}", q)); + let fragment = url.fragment().map_or(String::new(), |f| format!("#{}", f)); + format!("{}{}{}", path, query, fragment) + }; + let output = pattern.replace(&input, replacement); + + // No change, return original request + if input == output { + return Ok(request); + } + + let base_url_string = format!("{}://{}", url.scheme(), url.authority()); + let base_url = Url::parse(&base_url_string) + .map_err(|_| RequestBuilderException::UrlParseFailed(base_url_string.clone()))?; + + let options = Url::options().base_url(Some(&base_url)); + + let copy = options.parse(output.as_ref()).map_err(|_| { + RequestBuilderException::UrlParseFailed(format!("{}{}", base_url_string, output)) + })?; + + request.extend().url(copy).build() + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/method.rs b/crates/lang_handler/src/rewrite/rewriter/method.rs new file mode 100644 index 0000000..d5010d9 --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/method.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +use regex::{Error, Regex}; + +use super::{Request, RequestBuilderException, Rewriter}; + +/// Rewrite a request header using a given pattern and replacement. +pub struct MethodRewriter(Regex, String); + +impl MethodRewriter { + /// Construct a new MethodRewriter to replace the Request method using the + /// provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, MethodRewriter}; + /// # use lang_handler::Request; + /// let rewriter = MethodRewriter::new("PUT", "POST") + /// .expect("should be valid regex"); + /// ``` + pub fn new(pattern: R, replacement: S) -> Result, Error> + where + R: TryInto, + Error: From<>::Error>, + S: Into, + { + let pattern = pattern.try_into()?; + let replacement = replacement.into(); + Ok(Box::new(Self(pattern, replacement))) + } +} + +impl Rewriter for MethodRewriter { + /// Rewrite Request method using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, MethodRewriter}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = MethodRewriter::new("PUT", "POST") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .method("PUT") + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.method(), "POST".to_string()); + /// ``` + fn rewrite(&self, request: Request, _docroot: &Path) -> Result { + let MethodRewriter(pattern, replacement) = self; + + let input = request.method(); + let output = pattern.replace(input, replacement.clone()); + if output == input { + return Ok(request); + } + + request.extend().method(output).build() + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/mod.rs b/crates/lang_handler/src/rewrite/rewriter/mod.rs new file mode 100644 index 0000000..ccacb69 --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/mod.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +use crate::{ + rewrite::{Condition, ConditionalRewriter}, + Request, RequestBuilderException, +}; + +mod closure; +mod header; +mod href; +mod method; +mod path; +mod sequence; + +pub use header::HeaderRewriter; +pub use href::HrefRewriter; +pub use method::MethodRewriter; +pub use path::PathRewriter; +pub use sequence::RewriterSequence; + +/// A Rewriter simply applies its rewrite function to produce a possibly new +/// request object. +pub trait Rewriter: Sync + Send { + /// Rewrite a request using the rewriter's logic. + fn rewrite(&self, request: Request, docroot: &Path) -> Result; +} + +impl RewriterExt for T where T: Rewriter {} + +/// Extends Rewriter with combinators like `when` and `then`. +pub trait RewriterExt: Rewriter { + /// Add a condition to a rewriter to make it apply conditionally + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{Rewriter, RewriterExt, PathCondition, PathRewriter} + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = PathRewriter::new("^(/index\\.php)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let condition = PathCondition::new("^/index\\.php$") + /// .expect("should be valid regex"); + /// + /// let conditional_rewriter = rewriter.when(condition); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = conditional_rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/foo/index.php".to_string()); + /// ``` + fn when(self: Box, condition: Box) -> Box> + where + C: Condition + ?Sized, + { + ConditionalRewriter::new(self, condition) + } + + /// Add a rewriter to be applied in sequence. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{Rewriter, RewriterExt, PathRewriter, HeaderRewriter} + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let first = PathRewriter::new("^(/index.php)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let second = PathRewriter::new("foo/index", "foo/bar") + /// .expect("should be valid regex"); + /// + /// let sequence = first.then(second); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .header("TEST", "foo") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = sequence.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/foo/bar.php".to_string()); + /// ``` + fn then(self: Box, rewriter: Box) -> Box> + where + R: Rewriter + ?Sized, + { + RewriterSequence::new(self, rewriter) + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/path.rs b/crates/lang_handler/src/rewrite/rewriter/path.rs new file mode 100644 index 0000000..5bab304 --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/path.rs @@ -0,0 +1,80 @@ +use std::path::Path; + +use regex::{Error, Regex}; + +use super::{Request, RequestBuilderException, Rewriter}; + +/// Rewrite a request path using a given pattern and replacement. +pub struct PathRewriter { + pattern: Regex, + replacement: String, +} + +impl PathRewriter { + /// Construct PathRewriter using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, PathRewriter}; + /// # use lang_handler::Request; + /// let rewriter = PathRewriter::new("^(/foo)$", "/index.php") + /// .expect("should be valid regex"); + /// ``` + pub fn new(pattern: R, replacement: S) -> Result, Error> + where + R: TryInto, + Error: From<>::Error>, + S: Into, + { + let pattern = pattern.try_into()?; + let replacement = replacement.into(); + Ok(Box::new(Self { + pattern, + replacement, + })) + } +} + +impl Rewriter for PathRewriter { + /// Rewrite request path using the provided regex pattern and replacement. + /// + /// # Examples + /// + /// ``` + /// # use lang_handler::rewrite::{Rewriter, PathRewriter}; + /// # use lang_handler::Request; + /// # let docroot = std::env::temp_dir(); + /// let rewriter = PathRewriter::new("^(/foo)$", "/index.php") + /// .expect("should be valid regex"); + /// + /// let request = Request::builder() + /// .url("http://example.com/foo") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = rewriter.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/index.php".to_string()); + /// ``` + fn rewrite(&self, request: Request, _docroot: &Path) -> Result { + let PathRewriter { + pattern, + replacement, + } = self; + + let input = request.url().path(); + let output = pattern.replace(input, replacement.clone()); + + // No change, return original request + if input == output { + return Ok(request); + } + + let mut copy = request.url().clone(); + copy.set_path(output.as_ref()); + + request.extend().url(copy).build() + } +} diff --git a/crates/lang_handler/src/rewrite/rewriter/sequence.rs b/crates/lang_handler/src/rewrite/rewriter/sequence.rs new file mode 100644 index 0000000..0382530 --- /dev/null +++ b/crates/lang_handler/src/rewrite/rewriter/sequence.rs @@ -0,0 +1,76 @@ +use std::path::Path; + +use super::{Request, RequestBuilderException, Rewriter}; + +// Tested via Rewriter::then(...) doc-test + +/// This provides sequencing of rewriters. +pub struct RewriterSequence(Box, Box) +where + A: Rewriter + ?Sized, + B: Rewriter + ?Sized; + +impl RewriterSequence +where + A: Rewriter + ?Sized, + B: Rewriter + ?Sized, +{ + /// Constructs a new RewriterSequence with the given rewriters applied in + /// sequence. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::rewrite::{Rewriter, RewriterSequence, PathRewriter}; + /// let first = PathRewriter::new("^(.*)$", "/bar$1") + /// .expect("should be valid regex"); + /// + /// let second = PathRewriter::new("^(.*)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let sequence = RewriterSequence::new(first, second); + /// ``` + pub fn new(a: Box, b: Box) -> Box { + Box::new(Self(a, b)) + } +} + +impl Rewriter for RewriterSequence +where + A: Rewriter + ?Sized, + B: Rewriter + ?Sized, +{ + /// Rewrite a request using the first rewriter, then the second. + /// + /// # Examples + /// + /// ```rust + /// # use std::path::Path; + /// # use lang_handler::{ + /// # Request, + /// # rewrite::{Rewriter, RewriterSequence, PathRewriter} + /// # }; + /// # let docroot = std::env::temp_dir(); + /// let first = PathRewriter::new("^(.*)$", "/bar$1") + /// .expect("should be valid regex"); + /// + /// let second = PathRewriter::new("^(.*)$", "/foo$1") + /// .expect("should be valid regex"); + /// + /// let sequence = RewriterSequence::new(first, second); + /// + /// let request = Request::builder() + /// .url("http://example.com/index.php") + /// .build() + /// .expect("should build request"); + /// + /// let new_request = sequence.rewrite(request, &docroot) + /// .expect("should rewrite request"); + /// + /// assert_eq!(new_request.url().path(), "/foo/bar/index.php".to_string()); + /// ``` + fn rewrite(&self, request: Request, docroot: &Path) -> Result { + let request = self.0.rewrite(request, docroot)?; + self.1.rewrite(request, docroot) + } +} diff --git a/crates/lang_handler/src/test.rs b/crates/lang_handler/src/test.rs new file mode 100644 index 0000000..f9da5cd --- /dev/null +++ b/crates/lang_handler/src/test.rs @@ -0,0 +1,162 @@ +use std::{ + collections::HashMap, + env::temp_dir, + fs::{create_dir_all, File}, + io::{Error, ErrorKind, Write}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +/// A mock document root for testing purposes. +pub struct MockRoot(PathBuf); + +impl MockRoot { + /// Create a new MockRoot with the given document root and files. + /// + /// # Examples + /// + /// ``` + /// # use std::{collections::HashMap, env::temp_dir, path::PathBuf}; + /// # use lang_handler::MockRoot; + /// # let docroot = std::env::temp_dir().join("test"); + /// let files = HashMap::from([ + /// (PathBuf::new().join("file1.txt"), "Hello, world!".to_string()), + /// (PathBuf::new().join("file2.txt"), "Goodbye, world!".to_string()) + /// ]); + /// + /// let mock_root = MockRoot::new(&docroot, files) + /// .expect("should create mock root"); + /// ``` + pub fn new(docroot: D, files: H) -> Result + where + D: AsRef, + H: Into>, + { + let docroot = docroot.as_ref(); + create_dir_all(docroot)?; + + let map: HashMap = files.into(); + for (path, contents) in map.iter() { + let stripped = path.strip_prefix("/").unwrap_or(path); + + let file_path = docroot.join(stripped); + if let Some(parent) = file_path.parent() { + create_dir_all(parent)?; + } + + let mut file = File::create(file_path)?; + file.write_all(contents.as_bytes())?; + } + + // This unwrap should be safe due to creating the docroot base dir above. + Ok(Self( + docroot + .canonicalize() + .map_err(|err| Error::new(ErrorKind::Other, err))?, + )) + } + + /// Create a new MockRoot with the given document root and files. + /// + /// # Examples + /// + /// ``` + /// use lang_handler::MockRoot; + /// + /// let mock_root = MockRoot::builder() + /// .file("file1.txt", "Hello, world!") + /// .file("file2.txt", "Goodbye, world!") + /// .build() + /// .unwrap(); + /// ``` + pub fn builder() -> MockRootBuilder { + MockRootBuilder::default() + } +} + +// TODO: Somehow this happens too early? +// impl Drop for MockRoot { +// fn drop(&mut self) { +// remove_dir_all(&self.0).ok(); +// } +// } + +impl Deref for MockRoot { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MockRoot { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// A builder for creating a MockRoot with specified files. +#[derive(Debug)] +pub struct MockRootBuilder(PathBuf, HashMap); + +impl MockRootBuilder { + /// Create a new MockRootBuilder with the specified document root. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::MockRootBuilder; + /// # let docroot = std::env::temp_dir().join("test"); + /// let builder = MockRootBuilder::new(&docroot); + /// ``` + pub fn new(docroot: D) -> Self + where + D: AsRef, + { + Self(docroot.as_ref().to_owned(), HashMap::new()) + } + + /// Add a file to the MockRootBuilder. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::MockRootBuilder; + /// # let docroot = std::env::temp_dir().join("test"); + /// let builder = MockRootBuilder::new(&docroot) + /// .file("bar.txt", "Hello, world!"); + /// ``` + pub fn file(mut self, path: P, contents: C) -> MockRootBuilder + where + P: AsRef, + C: Into, + { + let path = path.as_ref().to_owned(); + let contents = contents.into(); + + self.1.insert(path, contents); + self + } + + /// Build the MockRoot. + /// + /// # Examples + /// + /// ```rust + /// # use lang_handler::MockRootBuilder; + /// # let docroot = std::env::temp_dir().join("test"); + /// let root = MockRootBuilder::new(&docroot) + /// .file("bar.txt", "Hello, world!") + /// .build() + /// .expect("should create mock root"); + /// ``` + pub fn build(self) -> Result { + MockRoot::new(self.0, self.1) + } +} + +impl Default for MockRootBuilder { + fn default() -> Self { + Self::new(temp_dir().join("php-temp-dir-base")) + } +} diff --git a/crates/php/Cargo.toml b/crates/php/Cargo.toml index 57c8503..6dbe27b 100644 --- a/crates/php/Cargo.toml +++ b/crates/php/Cargo.toml @@ -14,11 +14,11 @@ path = "src/main.rs" [dependencies] bytes = "1.10.1" hostname = "0.4.1" -lang_handler = { path = "../lang_handler", features = ["c"] } -libc = "0.2.171" -once_cell = "1.21.0" ext-php-rs = { git = "ssh://git@github.com/platformatic/ext-php-rs.git" } # ext-php-rs = { path = "../../../ext-php-rs" } +lang_handler = { path = "../lang_handler" } +libc = "0.2.171" +once_cell = "1.21.0" [build-dependencies] autotools = "0.2" diff --git a/crates/php/src/embed.rs b/crates/php/src/embed.rs index 2b4c20d..bd632c9 100644 --- a/crates/php/src/embed.rs +++ b/crates/php/src/embed.rs @@ -12,7 +12,7 @@ use ext_php_rs::{ zend::{try_catch, try_catch_first, ExecutorGlobals, SapiGlobals}, }; -use lang_handler::{Handler, Request, Response}; +use lang_handler::{rewrite::Rewriter, Handler, Request, Response}; use super::{ sapi::{ensure_sapi, Sapi}, @@ -22,7 +22,6 @@ use super::{ }; /// Embed a PHP script into a Rust application to handle HTTP requests. -#[derive(Debug)] pub struct Embed { docroot: PathBuf, args: Vec, @@ -30,6 +29,19 @@ pub struct Embed { // NOTE: This needs to hold the SAPI to keep it alive #[allow(dead_code)] sapi: Arc, + + rewriter: Option>, +} + +impl std::fmt::Debug for Embed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Embed") + .field("docroot", &self.docroot) + .field("args", &self.args) + .field("sapi", &self.sapi) + .field("rewriter", &"Box") + .finish() + } } // An embed instance may be constructed on the main thread and then shared @@ -49,10 +61,13 @@ impl Embed { /// let docroot = current_dir() /// .expect("should have current_dir"); /// - /// let embed = Embed::new(docroot); + /// let embed = Embed::new(docroot, None); /// ``` - pub fn new>(docroot: C) -> Result { - Embed::new_with_argv::(docroot, vec![]) + pub fn new(docroot: C, rewriter: Option>) -> Result + where + C: AsRef, + { + Embed::new_with_argv::(docroot, rewriter, vec![]) } /// Creates a new `Embed` instance with command-line arguments. @@ -66,13 +81,17 @@ impl Embed { /// let docroot = current_dir() /// .expect("should have current_dir"); /// - /// let embed = Embed::new_with_args(docroot, args()); + /// let embed = Embed::new_with_args(docroot, None, args()); /// ``` - pub fn new_with_args(docroot: C, args: Args) -> Result + pub fn new_with_args( + docroot: C, + rewriter: Option>, + args: Args, + ) -> Result where C: AsRef, { - Embed::new_with_argv(docroot, args.collect()) + Embed::new_with_argv(docroot, rewriter, args.collect()) } /// Creates a new `Embed` instance with command-line arguments. @@ -86,11 +105,15 @@ impl Embed { /// let docroot = current_dir() /// .expect("should have current_dir"); /// - /// let embed = Embed::new_with_argv(docroot, vec![ + /// let embed = Embed::new_with_argv(docroot, None, vec![ /// "foo" /// ]); /// ``` - pub fn new_with_argv(docroot: C, argv: Vec) -> Result + pub fn new_with_argv( + docroot: C, + rewriter: Option>, + argv: Vec, + ) -> Result where C: AsRef, S: AsRef + std::fmt::Debug, @@ -104,6 +127,7 @@ impl Embed { docroot, args: argv.iter().map(|v| v.as_ref().to_string()).collect(), sapi: ensure_sapi()?, + rewriter, }) } @@ -118,7 +142,7 @@ impl Embed { /// let docroot = current_dir() /// .expect("should have current_dir"); /// - /// let embed = Embed::new(&docroot) + /// let embed = Embed::new(&docroot, None) /// .expect("should have constructed Embed"); /// /// assert_eq!(embed.docroot(), docroot.as_path()); @@ -136,18 +160,20 @@ impl Handler for Embed { /// # Examples /// /// ``` - /// use std::env::current_dir; - /// use php::{Embed, Handler, Request, Response}; + /// use std::{env::temp_dir, fs::File, io::Write}; + /// use php::{Embed, Handler, Request, Response, MockRoot}; /// - /// let docroot = current_dir() - /// .expect("should have current_dir"); + /// let docroot = MockRoot::builder() + /// .file("index.php", "") + /// .build() + /// .expect("should prepare docroot"); /// - /// let handler = Embed::new(docroot) + /// let handler = Embed::new(docroot.clone(), None) /// .expect("should construct Embed"); /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("invalid url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -158,21 +184,32 @@ impl Handler for Embed { /// //assert_eq!(response.body(), "Hello, world!"); /// ``` fn handle(&self, request: Request) -> Result { + let docroot = self.docroot.clone(); + // Initialize the SAPI module self.sapi.startup()?; + // Get REQUEST_URI _first_ as it needs the pre-rewrite state. let url = request.url(); - - // Get original request URI and translate to final path - // TODO: Should do this with request rewriting later... let request_uri = url.path(); - let translated_path = translate_path(&self.docroot, request_uri)? + + // Apply request rewriting rules + let mut request = request.clone(); + if let Some(rewriter) = &self.rewriter { + request = rewriter + .rewrite(request, &docroot) + .map_err(EmbedRequestError::RequestRewriteError)?; + } + + let translated_path = translate_path(&docroot, request.url().path())? .display() .to_string(); - let path_translated = cstr(translated_path.clone()) - .map_err(|_| EmbedRequestError::FailedToSetRequestInfo("path_translated".into()))?; + + // Convert REQUEST_URI and PATH_TRANSLATED to C strings let request_uri = cstr(request_uri) .map_err(|_| EmbedRequestError::FailedToSetRequestInfo("request_uri".into()))?; + let path_translated = cstr(translated_path.clone()) + .map_err(|_| EmbedRequestError::FailedToSetRequestInfo("path_translated".into()))?; // Extract request method, query string, and headers let request_method = cstr(request.method()) @@ -202,7 +239,7 @@ impl Handler for Embed { let script_name = translated_path.clone(); let response = try_catch_first(|| { - RequestContext::for_request(request.clone(), self.docroot.clone()); + RequestContext::for_request(request.clone(), docroot.clone()); // Set server context { diff --git a/crates/php/src/exception.rs b/crates/php/src/exception.rs index f3ae903..b3ff80a 100644 --- a/crates/php/src/exception.rs +++ b/crates/php/src/exception.rs @@ -1,8 +1,15 @@ +use lang_handler::RequestBuilderException; + /// Set of exceptions which may be produced by php::Embed -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq, Hash)] pub enum EmbedStartError { + /// Document root not found, or not a directory DocRootNotFound(String), + + /// Failed to identify the executable location ExeLocationNotFound, + + /// Failed to initialize SAPI SapiNotInitialized, } @@ -20,23 +27,54 @@ impl std::fmt::Display for EmbedStartError { } } -#[derive(Debug)] +/// Errors which may occur during the request lifecycle +#[derive(Debug, PartialEq, Eq, Hash)] pub enum EmbedRequestError { + /// SAPI not started SapiNotStarted, + + /// SAPI not shutdown SapiNotShutdown, + + /// SAPI request not started SapiRequestNotStarted, + + /// Request context unavailable RequestContextUnavailable, + + /// Failed to encode a string to a C-style string CStringEncodeFailed(String), + // ExecuteError, + /// Exception thrown during script execution Exception(String), + + /// PHP bailout, usually due to a fatal error or exit call Bailout, + + /// Failed to build the response ResponseBuildError, + + /// Failed to find the current directory FailedToFindCurrentDirectory, + + /// Expected an absolute REQUEST_URI, but received a relative one ExpectedAbsoluteRequestUri(String), + + /// Script not found in the document root ScriptNotFound(String), + + /// Failed to determine the content type of the response FailedToDetermineContentType, + + /// Failed to set a server variable FailedToSetServerVar(String), + + /// Failed to set request info FailedToSetRequestInfo(String), + + /// Error during request rewriting + RequestRewriteError(RequestBuilderException), } impl std::fmt::Display for EmbedRequestError { @@ -69,6 +107,9 @@ impl std::fmt::Display for EmbedRequestError { EmbedRequestError::FailedToSetRequestInfo(name) => { write!(f, "Failed to set request info: \"{}\"", name) } + EmbedRequestError::RequestRewriteError(e) => { + write!(f, "Request rewrite error: {}", e) + } } } } diff --git a/crates/php/src/lib.rs b/crates/php/src/lib.rs index 96faefa..16b6ef0 100644 --- a/crates/php/src/lib.rs +++ b/crates/php/src/lib.rs @@ -1,5 +1,50 @@ +//! # Embedding PHP in Rust +//! +//! This library implements the PHP SAPI in Rust using Lang Handler, allowing +//! PHP to serve as a handler for HTTP requests dispatched from Rust. +//! +//! ## Example +//! +//! ```rust +//! use std::env::{args, current_dir}; +//! # use std::path::PathBuf; +//! # use php::MockRoot; +//! use php::{ +//! rewrite::{PathRewriter, Rewriter}, +//! Embed, Handler, Request, +//! }; +//! +//! let docroot = current_dir() +//! .expect("should have current_dir"); +//! # let docroot = MockRoot::builder() +//! # .file("index.php", "") +//! # .build() +//! # .expect("should prepare docroot"); +//! +//! let embed = Embed::new_with_args(docroot, None, args()) +//! .expect("should construct embed"); +//! +//! let request = Request::builder() +//! .method("POST") +//! .url("http://example.com/index.php") +//! .header("Content-Type", "text/html") +//! .header("Content-Length", 13.to_string()) +//! .body("Hello, World!") +//! .build() +//! .expect("should build request"); +//! +//! let response = embed +//! .handle(request.clone()) +//! .expect("should handle request"); +//! +//! assert_eq!(response.status(), 200); +//! assert_eq!(response.body(), "Hello, World!"); +//! println!("Response: {:#?}", response); +//! ``` + #![warn(rust_2018_idioms)] #![warn(clippy::dbg_macro, clippy::print_stdout)] +#![warn(missing_docs)] mod embed; mod exception; @@ -7,9 +52,14 @@ mod request_context; mod sapi; mod scopes; mod strings; +mod test; -pub use lang_handler::{Handler, Header, Headers, Request, RequestBuilder, Response, Url}; +pub use lang_handler::{ + rewrite, Handler, Header, Headers, Request, RequestBuilder, RequestBuilderException, Response, + ResponseBuilder, Url, +}; pub use embed::Embed; pub use exception::{EmbedRequestError, EmbedStartError}; pub use request_context::RequestContext; +pub use test::{MockRoot, MockRootBuilder}; diff --git a/crates/php/src/main.rs b/crates/php/src/main.rs index 6e60950..5cf31b5 100644 --- a/crates/php/src/main.rs +++ b/crates/php/src/main.rs @@ -1,14 +1,24 @@ -use php::{Embed, Handler, Request}; +use std::{env::current_dir, fs::File, io::Write, path::PathBuf}; + +use php::{ + rewrite::{PathRewriter, Rewriter}, + Embed, Handler, Request, +}; pub fn main() { - let docroot = std::env::current_dir().expect("should have current_dir"); + let _temp_file = TempFile::new("index.php", ""); + + let docroot = current_dir().expect("should have current_dir"); - let embed = Embed::new_with_args(docroot, std::env::args()).expect("should construct embed"); + let rewriter = PathRewriter::new("test", "index").expect("should be valid regex"); + + let maybe_rewriter: Option> = Some(rewriter); + let embed = Embed::new_with_args(docroot, maybe_rewriter, std::env::args()) + .expect("should construct embed"); let request = Request::builder() .method("POST") .url("http://example.com/test.php") - .expect("invalid url") .header("Content-Type", "text/html") .header("Content-Length", 13.to_string()) .body("Hello, World!") @@ -23,3 +33,24 @@ pub fn main() { println!("response: {:#?}", response); } + +struct TempFile(PathBuf); + +impl TempFile { + pub fn new(path: P, contents: S) -> Self + where + P: Into, + S: Into, + { + let path = path.into(); + let mut file = File::create(path.clone()).unwrap(); + file.write_all(contents.into().as_bytes()).unwrap(); + Self(path) + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + std::fs::remove_file(&self.0).unwrap(); + } +} diff --git a/crates/php/src/request_context.rs b/crates/php/src/request_context.rs index f226d24..146bd04 100644 --- a/crates/php/src/request_context.rs +++ b/crates/php/src/request_context.rs @@ -20,7 +20,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -53,7 +53,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -86,7 +86,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -118,7 +118,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -142,7 +142,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// @@ -167,7 +167,7 @@ impl RequestContext { /// /// let request = Request::builder() /// .method("GET") - /// .url("http://example.com").expect("should parse url") + /// .url("http://example.com") /// .build() /// .expect("should build request"); /// diff --git a/crates/php/src/strings.rs b/crates/php/src/strings.rs index 92882e6..88ae323 100644 --- a/crates/php/src/strings.rs +++ b/crates/php/src/strings.rs @@ -56,6 +56,7 @@ where { let docroot = docroot.as_ref().to_path_buf(); let request_uri = request_uri.as_ref(); + let relative_uri = request_uri.strip_prefix("/").map_err(|_| { let uri = request_uri.display().to_string(); EmbedRequestError::ExpectedAbsoluteRequestUri(uri) @@ -63,9 +64,52 @@ where let exact = docroot.join(relative_uri); - exact.join("index.php").canonicalize().or_else(|_| { - exact - .canonicalize() - .map_err(|_| EmbedRequestError::ScriptNotFound(exact.display().to_string())) - }) + // NOTE: String conversion is necessary. If Path::ends_with("/") is used it + // will discard the trailing slash first. + if request_uri.display().to_string().ends_with("/") { + try_path(exact.join("index.php")).or_else(|_| try_path(exact)) + } else { + try_path(exact) + } +} + +fn try_path>(path: P) -> Result { + let path = path.as_ref(); + let true_path = path + .canonicalize() + .map_err(|_| EmbedRequestError::ScriptNotFound(path.display().to_string()))?; + + if true_path.is_file() { + Ok(true_path) + } else { + Err(EmbedRequestError::ScriptNotFound( + path.display().to_string(), + )) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::MockRoot; + + #[test] + fn test_translate_path() { + let docroot = MockRoot::builder() + .file("/index.php", "") + .file("/foo/index.php", "") + .build() + .expect("should prepare docroot"); + + assert_eq!( + translate_path(docroot.clone(), "/foo/"), + Ok(docroot.join("foo/index.php")) + ); + assert_eq!( + translate_path(docroot.clone(), "/foo"), + Err(EmbedRequestError::ScriptNotFound( + docroot.join("foo").display().to_string() + )) + ); + } } diff --git a/crates/php/src/test.rs b/crates/php/src/test.rs new file mode 100644 index 0000000..e45f9ab --- /dev/null +++ b/crates/php/src/test.rs @@ -0,0 +1,113 @@ +use std::{ + collections::HashMap, + env::temp_dir, + fs::{create_dir_all, File}, + io::{Error, ErrorKind, Write}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +/// A mock document root for testing purposes. +pub struct MockRoot(PathBuf); + +impl MockRoot { + /// Create a new MockRoot with the given document root and files. + pub fn new(docroot: D, files: H) -> Result + where + D: AsRef, + H: Into>, + { + let docroot = docroot.as_ref(); + create_dir_all(docroot)?; + + let map: HashMap = files.into(); + for (path, contents) in map.iter() { + let stripped = path.strip_prefix("/").unwrap_or(path); + + let file_path = docroot.join(stripped); + if let Some(parent) = file_path.parent() { + create_dir_all(parent)?; + } + + let mut file = File::create(file_path)?; + file.write_all(contents.as_bytes())?; + } + + // This unwrap should be safe due to creating the docroot base dir above. + Ok(Self( + docroot + .canonicalize() + .map_err(|err| Error::new(ErrorKind::Other, err))?, + )) + } + + /// Create a new MockRoot with the given document root and files. + pub fn builder() -> MockRootBuilder { + MockRootBuilder::default() + } +} + +// TODO: Somehow this happens too early? +// impl Drop for MockRoot { +// fn drop(&mut self) { +// remove_dir_all(&self.0).ok(); +// } +// } + +impl Deref for MockRoot { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MockRoot { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AsRef for MockRoot { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +/// A builder for creating a MockRoot with a specified document root and files. +#[derive(Debug)] +pub struct MockRootBuilder(PathBuf, HashMap); + +impl MockRootBuilder { + /// Create a new MockRootBuilder with the specified document root. + pub fn new(docroot: D) -> Self + where + D: AsRef, + { + Self(docroot.as_ref().to_owned(), HashMap::new()) + } + + /// Add a file to the mock document root. + pub fn file(mut self, path: P, contents: C) -> MockRootBuilder + where + P: AsRef, + C: Into, + { + let path = path.as_ref().to_owned(); + let contents = contents.into(); + + self.1.insert(path, contents); + self + } + + /// Build the MockRoot with the specified document root and files. + pub fn build(self) -> Result { + MockRoot::new(self.0, self.1) + } +} + +impl Default for MockRootBuilder { + fn default() -> Self { + Self::new(temp_dir().join("php-temp-dir-base")) + } +} diff --git a/crates/php_node/Cargo.toml b/crates/php_node/Cargo.toml index 880bf8b..c6888b5 100644 --- a/crates/php_node/Cargo.toml +++ b/crates/php_node/Cargo.toml @@ -9,8 +9,8 @@ path = "src/lib.rs" [dependencies] # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "2.12.2", default-features = false, features = ["napi4"] } -napi-derive = "2.12.2" +napi = { version = "2.16.17", default-features = false, features = ["napi4"] } +napi-derive = "2.16.13" php = { path = "../php" } [build-dependencies] diff --git a/crates/php_node/src/lib.rs b/crates/php_node/src/lib.rs index fe4f4c9..4d583a5 100644 --- a/crates/php_node/src/lib.rs +++ b/crates/php_node/src/lib.rs @@ -4,9 +4,11 @@ extern crate napi_derive; mod headers; mod request; mod response; +mod rewriter; mod runtime; pub use headers::PhpHeaders; pub use request::PhpRequest; pub use response::PhpResponse; +pub use rewriter::PhpRewriter; pub use runtime::PhpRuntime; diff --git a/crates/php_node/src/request.rs b/crates/php_node/src/request.rs index e9e9c1a..30e2909 100644 --- a/crates/php_node/src/request.rs +++ b/crates/php_node/src/request.rs @@ -54,7 +54,7 @@ pub struct PhpRequestOptions { /// ``` #[napi(js_name = "Request")] pub struct PhpRequest { - request: Request, + pub(crate) request: Request, } // Future ideas: @@ -78,9 +78,7 @@ impl PhpRequest { /// ``` #[napi(constructor)] pub fn constructor(options: PhpRequestOptions) -> Result { - let mut builder: RequestBuilder = Request::builder() - .url(&options.url) - .map_err(|_| Error::from_reason(format!("Invalid URL \"{}\"", options.url)))?; + let mut builder: RequestBuilder = Request::builder().url(&options.url); if let Some(method) = options.method { builder = builder.method(method) @@ -92,9 +90,7 @@ impl PhpRequest { builder = builder .local_socket(&local_socket) - .map_err(|_| Error::from_reason(format!("Invalid local socket \"{}\"", local_socket)))? - .remote_socket(&remote_socket) - .map_err(|_| Error::from_reason(format!("Invalid remote socket \"{}\"", remote_socket)))?; + .remote_socket(&remote_socket); } if let Some(headers) = options.headers { diff --git a/crates/php_node/src/rewriter.rs b/crates/php_node/src/rewriter.rs new file mode 100644 index 0000000..bf3dbb9 --- /dev/null +++ b/crates/php_node/src/rewriter.rs @@ -0,0 +1,355 @@ +use std::{path::Path, str::FromStr}; + +// use napi::bindgen_prelude::*; +use napi::{Error, Result}; + +use php::{ + rewrite::{ + Condition, ConditionExt, ExistenceCondition, HeaderCondition, HeaderRewriter, HrefRewriter, + MethodCondition, MethodRewriter, NonExistenceCondition, PathCondition, PathRewriter, Rewriter, + RewriterExt, + }, + Request, RequestBuilderException, +}; + +use crate::PhpRequest; + +// +// Conditions +// + +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct PhpRewriteCondOptions { + #[napi(js_name = "type")] + pub cond_type: String, + pub args: Option>, +} + +pub enum PhpRewriteCond { + Exists, + Header(String, String), + Method(String), + NotExists, + Path(String), +} + +impl Condition for PhpRewriteCond { + fn matches(&self, request: &php::Request, docroot: &Path) -> bool { + match self { + PhpRewriteCond::Exists => ExistenceCondition.matches(request, docroot), + PhpRewriteCond::Header(name, pattern) => { + HeaderCondition::new(name.as_str(), pattern.as_str()) + .map(|v| v.matches(request, docroot)) + .unwrap_or_default() + } + PhpRewriteCond::Method(pattern) => MethodCondition::new(pattern.as_str()) + .map(|v| v.matches(request, docroot)) + .unwrap_or_default(), + PhpRewriteCond::NotExists => NonExistenceCondition.matches(request, docroot), + PhpRewriteCond::Path(pattern) => PathCondition::new(pattern.as_str()) + .map(|v| v.matches(request, docroot)) + .unwrap_or_default(), + } + } +} + +impl TryFrom<&PhpRewriteCondOptions> for Box { + type Error = Error; + + fn try_from(value: &PhpRewriteCondOptions) -> std::result::Result { + let PhpRewriteCondOptions { cond_type, args } = value; + let cond_type = cond_type.to_lowercase(); + let args = args.to_owned().unwrap_or(vec![]); + match cond_type.as_str() { + "exists" => { + if args.is_empty() { + Ok(Box::new(PhpRewriteCond::Exists)) + } else { + Err(Error::from_reason("Wrong number of parameters")) + } + } + "header" => match args.len() { + 2 => { + let name = args[0].to_owned(); + let pattern = args[1].to_owned(); + Ok(Box::new(PhpRewriteCond::Header(name, pattern))) + } + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + "method" => match args.len() { + 1 => Ok(Box::new(PhpRewriteCond::Method(args[0].to_owned()))), + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + "not_exists" | "not-exists" => { + if args.is_empty() { + Ok(Box::new(PhpRewriteCond::NotExists)) + } else { + Err(Error::from_reason("Wrong number of parameters")) + } + } + "path" => match args.len() { + 1 => Ok(Box::new(PhpRewriteCond::Path(args[0].to_owned()))), + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + _ => Err(Error::from_reason(format!( + "Unknown condition type: {}", + cond_type + ))), + } + } +} + +// +// Rewriters +// + +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct PhpRewriterOptions { + #[napi(js_name = "type")] + pub rewriter_type: String, + pub args: Vec, +} + +pub enum PhpRewriterType { + Header(String, String, String), + Href(String, String), + Method(String, String), + Path(String, String), +} + +impl Rewriter for PhpRewriterType { + fn rewrite( + &self, + request: Request, + docroot: &Path, + ) -> std::result::Result { + match self { + PhpRewriterType::Path(pattern, replacement) => { + PathRewriter::new(pattern.as_str(), replacement.as_str()) + .map(|v| v.rewrite(request.clone(), docroot)) + .unwrap_or(Ok(request)) + } + PhpRewriterType::Href(pattern, replacement) => { + HrefRewriter::new(pattern.as_str(), replacement.as_str()) + .map(|v| v.rewrite(request.clone(), docroot)) + .unwrap_or(Ok(request)) + } + PhpRewriterType::Method(pattern, replacement) => { + MethodRewriter::new(pattern.as_str(), replacement.as_str()) + .map(|v| v.rewrite(request.clone(), docroot)) + .unwrap_or(Ok(request)) + } + PhpRewriterType::Header(name, pattern, replacement) => { + HeaderRewriter::new(name.as_str(), pattern.as_str(), replacement.as_str()) + .map(|v| v.rewrite(request.clone(), docroot)) + .unwrap_or(Ok(request)) + } + } + } +} + +impl TryFrom<&PhpRewriterOptions> for Box { + type Error = Error; + + fn try_from(value: &PhpRewriterOptions) -> std::result::Result { + let PhpRewriterOptions { + rewriter_type, + args, + } = value; + let rewriter_type = rewriter_type.to_lowercase(); + match rewriter_type.as_str() { + "header" => match args.len() { + 3 => { + let name = args[0].to_owned(); + let pattern = args[1].to_owned(); + let replacement = args[2].to_owned(); + Ok(Box::new(PhpRewriterType::Header( + name, + pattern, + replacement, + ))) + } + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + "href" => match args.len() { + 2 => { + let pattern = args[0].to_owned(); + let replacement = args[1].to_owned(); + Ok(Box::new(PhpRewriterType::Href(pattern, replacement))) + } + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + "method" => match args.len() { + 2 => { + let pattern = args[0].to_owned(); + let replacement = args[1].to_owned(); + Ok(Box::new(PhpRewriterType::Method(pattern, replacement))) + } + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + "path" => match args.len() { + 2 => { + let pattern = args[0].to_owned(); + let replacement = args[1].to_owned(); + Ok(Box::new(PhpRewriterType::Path(pattern, replacement))) + } + _ => Err(Error::from_reason("Wrong number of parameters")), + }, + _ => Err(Error::from_reason(format!( + "Unknown rewriter type: {}", + rewriter_type + ))), + } + } +} + +// +// Conditional Rewriter +// + +pub enum OperationType { + And, + Or, +} +impl FromStr for OperationType { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + "and" | "&&" => Ok(OperationType::And), + "or" | "||" => Ok(OperationType::Or), + op => Err(Error::from_reason(format!( + "Unrecognized operation type: {}", + op + ))), + } + } +} + +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct PhpConditionalRewriterOptions { + pub operation: Option, + pub conditions: Option>, + pub rewriters: Vec, +} + +pub struct PhpConditionalRewriter(Box); + +impl Rewriter for PhpConditionalRewriter { + fn rewrite( + &self, + request: Request, + docroot: &Path, + ) -> std::result::Result { + self.0.rewrite(request, docroot) + } +} + +impl TryFrom<&PhpConditionalRewriterOptions> for Box { + type Error = Error; + + fn try_from(value: &PhpConditionalRewriterOptions) -> std::result::Result { + let value = value.clone(); + + let operation = value + .operation + .clone() + .unwrap_or("and".into()) + .parse::()?; + + let rewriter = value + .rewriters + .iter() + .try_fold(None::>, |state, next| { + let converted: std::result::Result, Error> = next.try_into(); + converted.map(|converted| { + let res: Option> = match state { + None => Some(converted), + Some(last) => Some(last.then(converted)), + }; + res + }) + })?; + + let condition = value.conditions.unwrap_or_default().iter().try_fold( + None::>, + |state, next| { + let converted: std::result::Result, Error> = next.try_into(); + converted.map(|converted| { + let res: Option> = match state { + None => Some(converted), + Some(last) => Some(match operation { + OperationType::Or => last.or(converted), + OperationType::And => last.and(converted), + }), + }; + res + }) + }, + )?; + + match rewriter { + None => Err(Error::from_reason("No rewriters provided")), + Some(rewriter) => Ok(Box::new(PhpConditionalRewriter(match condition { + None => rewriter, + Some(condition) => rewriter.when(condition), + }))), + } + } +} + +// +// Rewriter JS type +// + +#[napi(js_name = "Rewriter")] +pub struct PhpRewriter(Vec); + +#[napi] +impl PhpRewriter { + #[napi(constructor)] + pub fn constructor(options: Vec) -> Result { + Ok(PhpRewriter(options)) + } + + #[napi] + pub fn rewrite(&self, request: &PhpRequest, docroot: String) -> Result { + let rewriter = self.into_rewriter()?; + let docroot = Path::new(&docroot); + Ok(PhpRequest { + request: rewriter + .rewrite(request.request.to_owned(), docroot) + .map_err(|err| { + Error::from_reason(format!("Failed to rewrite request: {}", err.to_string())) + })?, + }) + } + + pub fn into_rewriter(&self) -> Result> { + if self.0.is_empty() { + return Err(Error::from_reason("No rewrite rules provided")); + } + + let rewriter = self + .0 + .iter() + .try_fold(None::>, |state, next| { + let converted: std::result::Result, Error> = next.try_into(); + converted.map(|converted| { + let res: Option> = match state { + None => Some(converted), + Some(last) => Some(last.then(converted)), + }; + res + }) + })?; + + match rewriter { + None => Err(Error::from_reason("No rewriters provided")), + Some(rewriter) => Ok(rewriter), + } + } +} diff --git a/crates/php_node/src/runtime.rs b/crates/php_node/src/runtime.rs index 78ba7bc..0461f78 100644 --- a/crates/php_node/src/runtime.rs +++ b/crates/php_node/src/runtime.rs @@ -5,11 +5,11 @@ use napi::{Env, Error, Result, Task}; use php::{Embed, EmbedRequestError, Handler, Request, Response}; -use crate::{PhpRequest, PhpResponse}; +use crate::{PhpRequest, PhpResponse, PhpRewriter}; /// Options for creating a new PHP instance. #[napi(object)] -#[derive(Clone, Default)] +#[derive(Default)] pub struct PhpOptions { /// The command-line arguments for the PHP instance. pub argv: Option>, @@ -17,6 +17,8 @@ pub struct PhpOptions { pub docroot: Option, /// Throw request errors pub throw_request_errors: Option, + /// Request rewriter + pub rewriter: Option>, } /// A PHP instance. @@ -60,6 +62,7 @@ impl PhpRuntime { docroot, argv, throw_request_errors, + rewriter, } = options.unwrap_or_default(); let docroot = docroot @@ -70,9 +73,15 @@ impl PhpRuntime { }) .map_err(|_| Error::from_reason("Could not determine docroot"))?; + let rewriter = if let Some(found) = rewriter { + Some(found.into_rewriter()?) + } else { + None + }; + let embed = match argv { - Some(argv) => Embed::new_with_argv(docroot, argv), - None => Embed::new(docroot), + Some(argv) => Embed::new_with_argv(docroot, rewriter, argv), + None => Embed::new(docroot, rewriter), } .map_err(|err| Error::from_reason(err.to_string()))?; diff --git a/index.d.ts b/index.d.ts index 0498aa7..46f35f8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,6 +47,19 @@ export interface PhpResponseOptions { /** The exception for the response. */ exception?: string } +export interface PhpRewriteCondOptions { + type: string + args?: Array +} +export interface PhpRewriterOptions { + type: string + args: Array +} +export interface PhpConditionalRewriterOptions { + operation?: string + conditions?: Array + rewriters: Array +} /** Options for creating a new PHP instance. */ export interface PhpOptions { /** The command-line arguments for the PHP instance. */ @@ -55,6 +68,8 @@ export interface PhpOptions { docroot?: string /** Throw request errors */ throwRequestErrors?: boolean + /** Request rewriter */ + rewriter?: Rewriter } export type PhpHeaders = Headers /** @@ -462,6 +477,11 @@ export declare class Response { */ get exception(): string | null } +export type PhpRewriter = Rewriter +export declare class Rewriter { + constructor(options: Array) + rewrite(request: Request, docroot: string): Request +} export type PhpRuntime = Php /** * A PHP instance. diff --git a/index.js b/index.js index 1feaf2b..83778b7 100644 --- a/index.js +++ b/index.js @@ -2,14 +2,16 @@ const { Php, Headers, Request, - Response + Response, + Rewriter } = getNativeBinding(process) module.exports = { Php, Headers, Request, - Response + Response, + Rewriter } function isMusl() {