skyzen 0.1.1

A fast, ergonomic HTTP framework that works everywhere
//! Form utilities module.

use crate::{
    extract::Extractor,
    header::{HeaderValue, CONTENT_TYPE},
    responder::Responder,
    Method, Request, Response, StatusCode,
};

use http_kit::http_error;
use serde::{de::DeserializeOwned, Serialize};
use serde_urlencoded::from_str;

/// Extract form from request body.
#[derive(Debug)]
pub struct Form<T: Send + Sync>(pub T);

#[allow(clippy::declare_interior_mutable_const)]
const APPLICATION_WWW_FORM_URLENCODED: HeaderValue =
    HeaderValue::from_static("application/x-www-form-urlencoded");

http_error!(
    /// Raised when the request content-type is not `application/x-www-form-urlencoded`.
    pub FormEncodeError, StatusCode::INTERNAL_SERVER_ERROR, "Failed to encode form data"
);

impl<T: Send + Sync + Serialize + DeserializeOwned + 'static> Responder for Form<T> {
    type Error = FormEncodeError;
    fn respond_to(self, _request: &Request, response: &mut Response) -> Result<(), Self::Error> {
        *response.body_mut() =
            http_kit::Body::from_form(&self.0).map_err(|_| FormEncodeError::new())?;
        response
            .headers_mut()
            .insert(CONTENT_TYPE, APPLICATION_WWW_FORM_URLENCODED);
        Ok(())
    }

    #[cfg(feature = "openapi")]
    fn openapi() -> Option<Vec<crate::openapi::ResponseSchema>> {
        Some(vec![crate::openapi::ResponseSchema {
            status: None,
            description: None,
            schema: crate::openapi::maybe_schema_of::<T>(),
            content_type: Some("application/x-www-form-urlencoded"),
        }])
    }

    #[cfg(feature = "openapi")]
    fn register_openapi_schemas(
        defs: &mut std::collections::BTreeMap<String, crate::openapi::SchemaRef>,
    ) {
        crate::openapi::maybe_register_schema_for::<T>(defs);
    }
}

/// Errors raised when parsing `application/x-www-form-urlencoded` data.
#[skyzen::error]
pub enum FormContentTypeError {
    /// The content type header is missing.
    #[error(
        "Expected content type `application/x-www-form-urlencoded`",
        status = StatusCode::BAD_REQUEST
    )]
    Missing,
    /// The content type does not match `application/x-www-form-urlencoded`.
    #[error(
        "Expected content type `application/x-www-form-urlencoded`",
        status = StatusCode::UNSUPPORTED_MEDIA_TYPE
    )]
    Unsupported,
    /// The payload could not be parsed as form data.
    #[error("Failed to parse form data", status = StatusCode::BAD_REQUEST)]
    InvalidPayload,
}

impl<T: Send + Sync + DeserializeOwned + 'static> Extractor for Form<T> {
    type Error = FormContentTypeError;
    async fn extract(request: &mut Request) -> Result<Self, Self::Error> {
        // A GET request carries the form in the query string and needs no content type.
        if request.method() == Method::GET {
            let data = request.uri().query().unwrap_or_default();
            return extract(data);
        }

        if let Some(content_type) = request.headers().get(CONTENT_TYPE) {
            if !is_form_content_type(content_type) {
                return Err(FormContentTypeError::Unsupported);
            }
        } else {
            return Err(FormContentTypeError::Missing);
        }

        let body = core::mem::replace(request.body_mut(), http_kit::Body::empty());
        let data = body.into_string().await.map_err(|error| {
            tracing::debug!(%error, "failed to read form request body");
            FormContentTypeError::InvalidPayload
        })?;
        extract(&data)
    }

    #[cfg(feature = "openapi")]
    fn openapi() -> Option<crate::openapi::ExtractorSchema> {
        Some(crate::openapi::ExtractorSchema {
            location: crate::openapi::ParameterLocation::Body,
            content_type: Some("application/x-www-form-urlencoded"),
            schema: crate::openapi::maybe_schema_of::<T>(),
        })
    }

    #[cfg(feature = "openapi")]
    fn register_openapi_schemas(
        defs: &mut std::collections::BTreeMap<String, crate::openapi::SchemaRef>,
    ) {
        crate::openapi::maybe_register_schema_for::<T>(defs);
    }
}

fn extract<T: Send + Sync + DeserializeOwned>(data: &str) -> Result<Form<T>, FormContentTypeError> {
    from_str(data)
        .map(Form)
        .map_err(|_| FormContentTypeError::InvalidPayload)
}

impl_deref!(Form);

fn is_form_content_type(value: &HeaderValue) -> bool {
    value
        .to_str()
        .ok()
        .and_then(|raw| raw.split(';').next())
        .is_some_and(|mime| {
            mime.trim()
                .eq_ignore_ascii_case("application/x-www-form-urlencoded")
        })
}

#[cfg(test)]
mod tests {
    use super::{Form, FormContentTypeError};
    use crate::{Body, Method};
    use http_kit::{header::CONTENT_TYPE, Request};
    use serde::Deserialize;
    use skyzen_core::Extractor;

    #[derive(Debug, Deserialize, PartialEq)]
    struct Payload {
        name: String,
        age: u8,
    }

    fn request_with_body(body: &'static [u8]) -> Request {
        let mut request = Request::new(Body::from_bytes(body.to_vec()));
        *request.method_mut() = Method::POST;
        *request.uri_mut() = "http://localhost/".parse().expect("invalid uri");
        request
    }

    #[tokio::test]
    async fn accepts_charset_param() {
        let mut request = request_with_body(b"name=Lexo&age=17");
        request.headers_mut().insert(
            CONTENT_TYPE,
            http_kit::header::HeaderValue::from_static(
                "application/x-www-form-urlencoded; charset=utf-8",
            ),
        );

        let Form(payload) = Form::<Payload>::extract(&mut request)
            .await
            .expect("form should parse");
        assert_eq!(
            payload,
            Payload {
                name: "Lexo".to_string(),
                age: 17
            }
        );
    }

    #[tokio::test]
    async fn rejects_missing_content_type_on_body() {
        let mut request = request_with_body(b"name=Lexo&age=17");
        let error = Form::<Payload>::extract(&mut request).await.unwrap_err();
        assert!(matches!(error, FormContentTypeError::Missing));
    }

    #[tokio::test]
    async fn rejects_invalid_payload() {
        let mut request = request_with_body(b"name=Lexo&age=oops");
        request.headers_mut().insert(
            CONTENT_TYPE,
            http_kit::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
        );
        let error = Form::<Payload>::extract(&mut request).await.unwrap_err();
        assert!(matches!(error, FormContentTypeError::InvalidPayload));
    }

    #[tokio::test]
    async fn rejects_wrong_content_type() {
        let mut request = request_with_body(b"name=Lexo&age=17");
        request.headers_mut().insert(
            CONTENT_TYPE,
            http_kit::header::HeaderValue::from_static("text/plain"),
        );
        let error = Form::<Payload>::extract(&mut request).await.unwrap_err();
        assert!(matches!(error, FormContentTypeError::Unsupported));
    }

    #[tokio::test]
    async fn parses_get_query_without_content_type() {
        let mut request = Request::new(Body::empty());
        *request.method_mut() = Method::GET;
        *request.uri_mut() = "http://localhost/?name=Lexo&age=17"
            .parse()
            .expect("invalid uri");

        let Form(payload) = Form::<Payload>::extract(&mut request)
            .await
            .expect("query form should parse");
        assert_eq!(
            payload,
            Payload {
                name: "Lexo".to_string(),
                age: 17
            }
        );
    }
}