fieldmasker 0.0.1

A utility for selecting and filtering response fields via field masks.
Documentation
//! Optional Axum integration helpers.
//!
//! Enabled with the `axum` feature. This module provides utilities to apply
//! `FieldMask` to responses within Axum handlers or extract masks from requests.
//!
//! This module is re-exported at the crate root as `axum_integration` when enabled.
//!
//! See `crates/fieldmasker/examples/03_axum_required.rs`.
//! See `crates/fieldmasker/examples/04_axum_optional.rs`

use super::*;
use crate::spec::MaskSpec;
use crate::validate::validate_mask;
use axum::extract::FromRequestParts;
use axum::http::{header, request::Parts, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use std::marker::PhantomData;

/// Rejection type returned by the mask extractors.
#[derive(Debug, Clone)]
pub struct MaskRejection {
    /// The HTTP status code that should be returned to the client.
    ///
    /// Indicates the general category of the error (e.g., [`StatusCode::BAD_REQUEST`]
    /// for invalid input).
    pub status: StatusCode,

    /// A short, static identifier for the error type.
    ///
    /// This is intended for programmatic use, such as mapping specific error codes
    /// to custom client-side handling. Example: `"invalid_syntax"`.
    pub code: &'static str,

    /// A human-readable description of the error.
    ///
    /// Provides additional context or details about why the mask extraction failed.
    /// This message is intended for debugging and should be suitable for returning
    /// to API clients.
    pub message: String,
}

impl MaskRejection {
    fn invalid_argument(msg: impl Into<String>) -> Self {
        Self {
            status: StatusCode::BAD_REQUEST,
            code: "INVALID_ARGUMENT",
            message: msg.into(),
        }
    }

    fn missing_mask() -> Self {
        Self::invalid_argument("missing required field mask (use ?fields=... or 'x-fields' header)")
    }
}

impl IntoResponse for MaskRejection {
    fn into_response(self) -> Response {
        // Plain text; no JSON dependency. Include a simple machine-readable code prefix.
        let mut res = (self.status, format!("{}: {}", self.code, self.message)).into_response();
        res.headers_mut().insert(
            header::CONTENT_TYPE,
            HeaderValue::from_static("text/plain; charset=utf-8"),
        );
        res
    }
}

#[derive(Deserialize)]
struct QueryFields {
    #[serde(rename = "fields")]
    fields: Option<String>,
}

fn extract_mask_from_parts(parts: &Parts) -> Option<String> {
    // 1) Query: ?fields=...
    if let Some(qs) = parts.uri.query() {
        if let Ok(q) = serde_urlencoded::from_str::<QueryFields>(qs) {
            if let Some(v) = q.fields {
                let v = v.trim();
                if !v.is_empty() {
                    return Some(v.to_string());
                }
            }
        }
    }

    // 2) Header "x-fields" (case-insensitive)
    if let Some(val) = parts.headers.get("x-fields") {
        if let Ok(s) = val.to_str() {
            let s = s.trim();
            if !s.is_empty() {
                return Some(s.to_string());
            }
        }
    }

    None
}

/// **Required** mask: omitting the mask rejects the request.
///
/// Usage:
/// ```rust,ignore
/// async fn handler(MaskRequired::<Resp>(mask, _): MaskRequired<Resp>) -> Json<Masked<Resp>> { ... }
/// ```
pub struct MaskRequired<T>(pub FieldMask, pub PhantomData<T>);

/// **Optional** mask: if absent, behaves as `"*"` (all fields).
///
/// Usage:
/// ```rust,ignore
/// async fn handler(MaskOptional::<Resp>(mask, _): MaskOptional<Resp>) -> Json<Masked<Resp>> { ... }
/// ```
pub struct MaskOptional<T>(pub FieldMask, pub PhantomData<T>);

impl<S, T> FromRequestParts<S> for MaskRequired<T>
where
    S: Send + Sync,
    T: MaskSpec,
{
    type Rejection = MaskRejection;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let raw = extract_mask_from_parts(parts).ok_or_else(MaskRejection::missing_mask)?;
        let mask =
            FieldMask::parse(&raw).map_err(|e| MaskRejection::invalid_argument(e.to_string()))?;
        validate_mask(&mask, T::mask_spec())
            .map_err(|e| MaskRejection::invalid_argument(e.to_string()))?;
        Ok(MaskRequired(mask, PhantomData))
    }
}

impl<S, T> FromRequestParts<S> for MaskOptional<T>
where
    S: Send + Sync,
    T: MaskSpec,
{
    type Rejection = MaskRejection;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let mask = match extract_mask_from_parts(parts) {
            None => FieldMask::all(),
            Some(raw) => FieldMask::parse(&raw)
                .map_err(|e| MaskRejection::invalid_argument(e.to_string()))?,
        };
        validate_mask(&mask, T::mask_spec())
            .map_err(|e| MaskRejection::invalid_argument(e.to_string()))?;
        Ok(MaskOptional(mask, PhantomData))
    }
}

// Intentionally **no** IntoResponse for Masked<T> here.
// In handlers, return `axum::Json(Masked<T>)` (or any other responder) explicitly.