soaprs-http 0.3.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Validation- and documentation-neutral API contract metadata.

use std::fmt;

use http::StatusCode;
use soaprs_core::{SoapError, SoapResult};

use crate::ContractId;

/// Validated media type without a dependency on a serializer or schema format.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MediaType(String);

impl MediaType {
    /// Validates a `type/subtype` media type, optionally with parameters.
    pub fn new(value: impl Into<String>) -> SoapResult<Self> {
        let value = value.into();
        let mut segments = value.split(';');
        let essence = segments.next().unwrap_or_default().trim();
        let Some((kind, subtype)) = essence.split_once('/') else {
            return Err(SoapError::validation(format!(
                "invalid media type `{value}`"
            )));
        };
        if !valid_http_token(kind)
            || !valid_http_token(subtype)
            || value.chars().any(|character| character.is_control())
            || segments.any(|parameter| !valid_media_parameter(parameter.trim()))
        {
            return Err(SoapError::validation(format!(
                "invalid media type `{value}`"
            )));
        }
        Ok(Self(value))
    }

    /// Returns the complete media type including any parameters.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Reports case-insensitive equality for contract registration.
    pub fn equivalent_to(&self, other: &Self) -> bool {
        self.0.eq_ignore_ascii_case(&other.0)
    }

    /// Returns `application/json`.
    pub fn json() -> Self {
        Self("application/json".to_owned())
    }
}

impl fmt::Display for MediaType {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

fn valid_http_token(value: &str) -> bool {
    !value.is_empty()
        && value.chars().all(|character| {
            character.is_ascii_alphanumeric()
                || matches!(
                    character,
                    '!' | '#'
                        | '$'
                        | '%'
                        | '&'
                        | '\''
                        | '*'
                        | '+'
                        | '-'
                        | '.'
                        | '^'
                        | '_'
                        | '`'
                        | '|'
                        | '~'
                )
        })
}

fn valid_media_parameter(parameter: &str) -> bool {
    let Some((name, value)) = parameter.split_once('=') else {
        return false;
    };
    if !valid_http_token(name.trim()) {
        return false;
    }
    let value = value.trim();
    valid_http_token(value)
        || (value.len() >= 2
            && value.starts_with('"')
            && value.ends_with('"')
            && valid_quoted_value(&value[1..value.len() - 1]))
}

fn valid_quoted_value(value: &str) -> bool {
    let mut escaped = false;
    for character in value.chars() {
        if character.is_control() {
            return false;
        }
        if escaped {
            escaped = false;
        } else if character == '\\' {
            escaped = true;
        } else if character == '"' {
            return false;
        }
    }
    !escaped
}

/// Location from which an HTTP adapter validates or documents request data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RequestContractLocation {
    /// Request body after framework extraction.
    Body,
    /// Parsed query parameters.
    Query,
    /// Parsed route parameters.
    Path,
    /// Request headers.
    Headers,
}

/// Logical validation/schema contract attached to part of a request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestContract {
    /// Contract resolved by a validation or schema adapter.
    pub id: ContractId,
    /// Request component covered by the contract.
    pub location: RequestContractLocation,
    /// Content type expected for body contracts.
    pub content_type: Option<MediaType>,
}

impl RequestContract {
    /// Creates a request contract reference.
    pub const fn new(id: ContractId, location: RequestContractLocation) -> Self {
        Self {
            id,
            location,
            content_type: None,
        }
    }

    /// Associates the contract with one body content type.
    #[must_use]
    pub fn content_type(mut self, content_type: MediaType) -> Self {
        self.content_type = Some(content_type);
        self
    }
}

/// Logical response schema contract attached to a status code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseContract {
    /// HTTP response status represented by the contract.
    pub status: StatusCode,
    /// Contract resolved by a schema adapter.
    pub id: ContractId,
    /// Serialized response content type.
    pub content_type: MediaType,
}

impl ResponseContract {
    /// Creates a JSON response contract reference.
    pub fn json(status: StatusCode, id: ContractId) -> Self {
        Self {
            status,
            id,
            content_type: MediaType::json(),
        }
    }
}

/// Request and response contracts attached to one endpoint.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EndpointContracts {
    requests: Vec<RequestContract>,
    responses: Vec<ResponseContract>,
}

impl EndpointContracts {
    /// Adds or replaces the contract for one request location.
    pub fn add_request(&mut self, contract: RequestContract) {
        if let Some(existing) = self
            .requests
            .iter_mut()
            .find(|item| same_request_slot(item, &contract))
        {
            *existing = contract;
        } else {
            self.requests.push(contract);
        }
    }

    /// Adds or replaces the response contract for one status code.
    pub fn add_response(&mut self, contract: ResponseContract) {
        if let Some(existing) = self.responses.iter_mut().find(|item| {
            item.status == contract.status
                && item.content_type.equivalent_to(&contract.content_type)
        }) {
            *existing = contract;
        } else {
            self.responses.push(contract);
        }
    }

    /// Returns request contract references in registration order.
    pub fn requests(&self) -> &[RequestContract] {
        &self.requests
    }

    /// Returns response contract references in registration order.
    pub fn responses(&self) -> &[ResponseContract] {
        &self.responses
    }
}

fn same_request_slot(left: &RequestContract, right: &RequestContract) -> bool {
    left.location == right.location
        && (left.location != RequestContractLocation::Body
            || match (&left.content_type, &right.content_type) {
                (Some(left), Some(right)) => left.equivalent_to(right),
                (None, None) => true,
                _ => false,
            })
}

/// Human-facing operation documentation independent from OpenAPI structures.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OperationDocumentation {
    /// Short operation summary.
    pub summary: Option<String>,
    /// Longer operation description.
    pub description: Option<String>,
    /// Whether clients should stop adopting this operation.
    pub deprecated: bool,
}

impl OperationDocumentation {
    /// Sets a non-empty operation summary.
    pub fn summary(mut self, summary: impl Into<String>) -> SoapResult<Self> {
        self.summary = Some(non_empty("operation summary", summary.into())?);
        Ok(self)
    }

    /// Sets a non-empty operation description.
    pub fn description(mut self, description: impl Into<String>) -> SoapResult<Self> {
        self.description = Some(non_empty("operation description", description.into())?);
        Ok(self)
    }

    /// Marks the operation as deprecated.
    #[must_use]
    pub const fn deprecated(mut self) -> Self {
        self.deprecated = true;
        self
    }

    /// Validates documentation after direct public-field mutation.
    pub fn validate(&self) -> SoapResult<()> {
        if self
            .summary
            .as_ref()
            .is_some_and(|value| value.trim().is_empty())
        {
            return Err(SoapError::validation("operation summary cannot be empty"));
        }
        if self
            .description
            .as_ref()
            .is_some_and(|value| value.trim().is_empty())
        {
            return Err(SoapError::validation(
                "operation description cannot be empty",
            ));
        }
        Ok(())
    }
}

fn non_empty(kind: &str, value: String) -> SoapResult<String> {
    if value.trim().is_empty() {
        Err(SoapError::validation(format!("{kind} cannot be empty")))
    } else {
        Ok(value)
    }
}

#[cfg(test)]
mod tests {
    use http::StatusCode;

    use super::{
        EndpointContracts, MediaType, RequestContract, RequestContractLocation, ResponseContract,
    };
    use crate::ContractId;

    #[test]
    fn contracts_replace_one_logical_content_slot_and_preserve_other_formats() {
        let mut contracts = EndpointContracts::default();
        let Some(first) = ContractId::new("users.request.v1").ok() else {
            panic!("valid contract id");
        };
        let Some(second) = ContractId::new("users.request.v2").ok() else {
            panic!("valid contract id");
        };
        contracts.add_request(
            RequestContract::new(first, RequestContractLocation::Body)
                .content_type(MediaType::json()),
        );
        contracts.add_request(
            RequestContract::new(second.clone(), RequestContractLocation::Body)
                .content_type(MediaType::json()),
        );
        contracts.add_response(ResponseContract::json(StatusCode::OK, second));

        let Some(protobuf) = MediaType::new("application/protobuf").ok() else {
            panic!("valid media type");
        };
        let Some(protobuf_id) = ContractId::new("users.response.protobuf").ok() else {
            panic!("valid contract id");
        };
        contracts.add_response(ResponseContract {
            status: StatusCode::OK,
            id: protobuf_id,
            content_type: protobuf,
        });

        assert_eq!(contracts.requests().len(), 1);
        assert_eq!(contracts.responses().len(), 2);
        assert_eq!(contracts.requests()[0].id.as_str(), "users.request.v2");
    }

    #[test]
    fn media_types_require_a_valid_type_and_subtype() {
        assert!(MediaType::new("application/problem+json").is_ok());
        assert!(MediaType::new("application/json; charset=utf-8").is_ok());
        assert!(MediaType::new("application/json; profile=\"public api\"").is_ok());
        assert!(MediaType::new("json").is_err());
        assert!(MediaType::new("application/white space").is_err());
        assert!(MediaType::new("application/json; charset").is_err());
    }
}