request.rs
//! Gemini Requests
//!
//! Gemini requests consist of a single URL (which means the scheme must be
//! present). The format of `gemini://` URLs is defined in Section 1.2 of [the
//! Gemini spec](https://gemini.circumlunar.space/docs/specification.html).
use std::{borrow::Cow, fmt::Display, marker::PhantomData};
use thiserror::Error;
pub use url::Url;
pub use request_type::{Any, Gemini};
mod request_type;
/// A Gemini request.
///
/// Gemini requests consist of a single URL, although this URL does not
/// necessarily have to be located via the `gemini://` scheme (such that gemini
/// servers are able to proxy requests to `gopher` resources, or other schemes).
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub struct Request<T: request_type::RequestType> {
url: Url,
_phantom: PhantomData<T>,
}
/// A request that may be for any scheme.
pub type AnyRequest = Request<request_type::Any>;
/// A request that is known to be a valid Gemini URL.
pub type GeminiRequest = Request<request_type::Gemini>;
/// Error to indicate a failure in constructing a `Request`.
///
/// Errors will be returned in the case of invalid URLs with respect to the
/// `url` crate, or URLs with a length greater than 1024 bytes.
#[derive(Debug, Copy, Clone, Error)]
pub struct InvalidRequest {
_priv: (),
}
impl Display for InvalidRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid request")
}
}
impl InvalidRequest {
fn new() -> Self {
InvalidRequest { _priv: () }
}
}
impl<T: request_type::RequestType> Request<T> {
/// Maximum length, in bytes, of a request URL.
pub const MAX_URL_LEN: usize = 1024;
/// Default port for the Gemini protocol.
pub const DEFAULT_PORT: u16 = 1965;
/// The Gemini scheme, which can be prepended to a URI to attempt to see if
/// it is a valid Gemini URL.
pub const GEMINI_SCHEME: &'static str = "gemini";
/// Attempt to construct a `Request` from a given URI that may not have a
/// scheme.
pub fn from_uri(uri: &str) -> Result<AnyRequest, InvalidRequest> {
let uri = if uri.contains("://") {
Cow::from(uri)
} else {
format!("{}://{}", Self::GEMINI_SCHEME, uri).into()
};
let url = Url::parse(&uri).map_err(|_| InvalidRequest::new())?;
Self::from_url(url)
}
/// Attempt to construct an `AnyRequest` from a given `Url`.
///
/// Returns an error if the URL's length exceeds `Self::MAX_URL_LEN`.
pub fn from_url(url: Url) -> Result<AnyRequest, InvalidRequest> {
if url.as_str().len() > Self::MAX_URL_LEN {
Err(InvalidRequest::new())
} else {
Ok(Request {
url,
_phantom: PhantomData,
})
}
}
/// Construct a `GeminiRequest` from the necessary components.
pub fn gemini_request(
host: &str,
port: Option<u16>,
path: &str,
) -> Result<GeminiRequest, InvalidRequest> {
let url = format!(
"{}://{}:{}/{}",
Self::GEMINI_SCHEME,
host,
port.unwrap_or(1965),
path
);
let url = Url::parse(url.as_str()).map_err(|_| InvalidRequest::new())?;
if url.as_str().len() > Self::MAX_URL_LEN {
Err(InvalidRequest::new())
} else {
Ok(Request {
url,
_phantom: PhantomData,
})
}
}
/// Returns `true` if this `Request` can be interpreted as containing
/// a resource located via the `gemini://` scheme.
pub fn is_gemini_request(&self) -> bool {
self.scheme() == Self::GEMINI_SCHEME
&& self.url.has_authority()
&& self.url.username().is_empty()
&& self.url.password().is_none()
&& self.url.host().is_some()
&& !self.url.cannot_be_a_base()
}
/// Return the scheme for the underlying url.
pub fn scheme(&self) -> &str {
self.url.scheme()
}
/// Return a reference to the underlying url.
pub fn url(&self) -> &Url {
&self.url
}
}
impl AnyRequest {
/// Validate that this `Request` can be made via `gemini://`, including
/// setting the port if not already done.
pub fn into_gemini_request(mut self) -> Result<GeminiRequest, InvalidRequest> {
if self.is_gemini_request() {
if self.url.port().is_none() {
self.url.set_port(Some(Self::DEFAULT_PORT)).unwrap()
}
Ok(Request {
url: self.url,
_phantom: PhantomData,
})
} else {
Err(InvalidRequest::new())
}
}
}
impl GeminiRequest {
/// Return the hostname associated with the Gemini request.
pub fn host(&self) -> &str {
self.url.host_str().unwrap()
}
/// Return the path portion of the Gemini request url.
pub fn path(&self) -> &str {
self.url.path()
}
/// Return the port associated with the Gemini request.
pub fn port(&self) -> u16 {
self.url.port().unwrap()
}
}
/// Parser
#[cfg(feature = "parsers")]
pub mod parse {
use std::str;
use nom::{
bytes::streaming::{tag, take_until},
combinator::map_res,
error::context,
sequence::terminated,
IResult,
};
use super::*;
/// `nom` parser for a request string. Invalid utf-8 will be rejected, but
/// non-"gemini://" urls are allowed as per the spec.
pub fn request(input: &[u8]) -> IResult<&[u8], AnyRequest> {
context(
"request",
map_res(terminated(take_until("\r\n"), tag("\r\n")), |bs| {
let s = str::from_utf8(bs).map_err(|_| InvalidRequest::new())?;
AnyRequest::from_uri(s)
}),
)(input)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_gemini_request() {
let bytes = b"gemini://foo.bar.baz:1966/path\r\n";
assert!(request(bytes).unwrap().1.is_gemini_request())
}
#[test]
fn test_gemini_request_no_port() {
let bytes = b"gemini://foo.bar.baz/path\r\n";
assert!(request(bytes).unwrap().1.is_gemini_request())
}
#[test]
fn test_generic_request() {
let bytes = b"http://goggle.com/snoop\r\n";
assert!(request(bytes).is_ok())
}
// NB: looking at this test, im unsure if this behavior is desirable.
// maybe there should be a canonical parser that strictly requires
// a scheme, since that's what the gemini spec does, along with a uri
// parser that tries to read a "link-like-thing" as a gemini link?
#[test]
fn test_no_scheme() {
let bytes = b"foo.bar.baz/path\r\n";
assert!(request(bytes).unwrap().1.is_gemini_request())
}
}
}