rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::fmt;
use std::str::FromStr;

use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
    pub name: String,
    pub value: String,
    pub max_age: Option<i64>,
    pub path: String,
    pub domain: Option<String>,
    pub secure: bool,
    pub httponly: bool,
    pub samesite: SameSite,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
    Strict,
    Lax,
    None,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum CookieError {
    #[error("cookie header is empty")]
    Empty,
    #[error("cookie header must start with name=value")]
    MissingNameValue,
    #[error("cookie name cannot be empty")]
    EmptyName,
    #[error("invalid Max-Age value: {0}")]
    InvalidMaxAge(String),
    #[error("invalid SameSite value: {0}")]
    InvalidSameSite(String),
    #[error("missing value for cookie attribute: {0}")]
    MissingAttributeValue(&'static str),
}

impl Cookie {
    #[must_use]
    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            value: value.into(),
            max_age: None,
            path: "/".to_string(),
            domain: None,
            secure: false,
            httponly: false,
            samesite: SameSite::Lax,
        }
    }

    #[must_use]
    pub fn to_header_value(&self) -> String {
        let mut parts = vec![format!("{}={}", self.name, self.value)];

        if let Some(max_age) = self.max_age {
            parts.push(format!("Max-Age={max_age}"));
        }

        parts.push(format!("Path={}", self.path));

        if let Some(domain) = &self.domain {
            parts.push(format!("Domain={domain}"));
        }

        if self.secure {
            parts.push("Secure".to_string());
        }

        if self.httponly {
            parts.push("HttpOnly".to_string());
        }

        parts.push(format!("SameSite={}", self.samesite));
        parts.join("; ")
    }

    pub fn parse(header: &str) -> Result<Self, CookieError> {
        let mut parts = header.split(';').map(str::trim);
        let first = parts.next().ok_or(CookieError::Empty)?;

        if first.is_empty() {
            return Err(CookieError::Empty);
        }

        let (name, value) = first.split_once('=').ok_or(CookieError::MissingNameValue)?;
        if name.is_empty() {
            return Err(CookieError::EmptyName);
        }

        let mut cookie = Self::new(name, value);

        for attribute in parts {
            if attribute.is_empty() {
                continue;
            }

            let Some((key, raw_value)) = attribute
                .split_once('=')
                .map(|(key, value)| (key.trim(), value.trim()))
                .or(Some((attribute, "")))
            else {
                continue;
            };

            match key.to_ascii_lowercase().as_str() {
                "max-age" => {
                    if raw_value.is_empty() {
                        return Err(CookieError::MissingAttributeValue("Max-Age"));
                    }
                    cookie.max_age = Some(
                        raw_value
                            .parse()
                            .map_err(|_| CookieError::InvalidMaxAge(raw_value.to_string()))?,
                    );
                }
                "path" => {
                    if raw_value.is_empty() {
                        return Err(CookieError::MissingAttributeValue("Path"));
                    }
                    cookie.path = raw_value.to_string();
                }
                "domain" => {
                    if raw_value.is_empty() {
                        return Err(CookieError::MissingAttributeValue("Domain"));
                    }
                    cookie.domain = Some(raw_value.to_string());
                }
                "secure" => cookie.secure = true,
                "httponly" => cookie.httponly = true,
                "samesite" => {
                    if raw_value.is_empty() {
                        return Err(CookieError::MissingAttributeValue("SameSite"));
                    }
                    cookie.samesite = SameSite::from_str(raw_value)?;
                }
                _ => {}
            }
        }

        Ok(cookie)
    }
}

impl FromStr for SameSite {
    type Err = CookieError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        if value.eq_ignore_ascii_case("strict") {
            Ok(Self::Strict)
        } else if value.eq_ignore_ascii_case("lax") {
            Ok(Self::Lax)
        } else if value.eq_ignore_ascii_case("none") {
            Ok(Self::None)
        } else {
            Err(CookieError::InvalidSameSite(value.to_string()))
        }
    }
}

impl fmt::Display for SameSite {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::Strict => "Strict",
            Self::Lax => "Lax",
            Self::None => "None",
        };
        f.write_str(value)
    }
}

#[cfg(test)]
mod tests {
    use super::{Cookie, CookieError, SameSite};

    #[test]
    fn new_sets_expected_defaults() {
        let cookie = Cookie::new("sessionid", "abc123");

        assert_eq!(cookie.name, "sessionid");
        assert_eq!(cookie.value, "abc123");
        assert_eq!(cookie.max_age, None);
        assert_eq!(cookie.path, "/");
        assert_eq!(cookie.domain, None);
        assert!(!cookie.secure);
        assert!(!cookie.httponly);
        assert_eq!(cookie.samesite, SameSite::Lax);
    }

    #[test]
    fn to_header_value_serializes_all_configured_attributes() {
        let mut cookie = Cookie::new("sessionid", "abc123");
        cookie.max_age = Some(3600);
        cookie.path = "/admin".to_string();
        cookie.domain = Some("example.com".to_string());
        cookie.secure = true;
        cookie.httponly = true;
        cookie.samesite = SameSite::Strict;

        assert_eq!(
            cookie.to_header_value(),
            "sessionid=abc123; Max-Age=3600; Path=/admin; Domain=example.com; Secure; HttpOnly; SameSite=Strict"
        );
    }

    #[test]
    fn parse_reads_set_cookie_header() {
        let cookie = Cookie::parse(
            "sessionid=abc123; Max-Age=3600; Path=/admin; Domain=example.com; Secure; HttpOnly; SameSite=Strict",
        )
        .unwrap();

        assert_eq!(cookie.name, "sessionid");
        assert_eq!(cookie.value, "abc123");
        assert_eq!(cookie.max_age, Some(3600));
        assert_eq!(cookie.path, "/admin");
        assert_eq!(cookie.domain.as_deref(), Some("example.com"));
        assert!(cookie.secure);
        assert!(cookie.httponly);
        assert_eq!(cookie.samesite, SameSite::Strict);
    }

    #[test]
    fn parse_rejects_invalid_values() {
        assert_eq!(Cookie::parse(""), Err(CookieError::Empty));
        assert_eq!(Cookie::parse("Secure"), Err(CookieError::MissingNameValue));
        assert_eq!(
            Cookie::parse("sessionid=abc123; Max-Age=forever"),
            Err(CookieError::InvalidMaxAge("forever".to_string()))
        );
        assert_eq!(
            Cookie::parse("sessionid=abc123; SameSite=sideways"),
            Err(CookieError::InvalidSameSite("sideways".to_string()))
        );
    }
}