use std::fmt;
use http::StatusCode;
use soaprs_core::{SoapError, SoapResult};
use crate::ContractId;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MediaType(String);
impl MediaType {
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))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn equivalent_to(&self, other: &Self) -> bool {
self.0.eq_ignore_ascii_case(&other.0)
}
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RequestContractLocation {
Body,
Query,
Path,
Headers,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestContract {
pub id: ContractId,
pub location: RequestContractLocation,
pub content_type: Option<MediaType>,
}
impl RequestContract {
pub const fn new(id: ContractId, location: RequestContractLocation) -> Self {
Self {
id,
location,
content_type: None,
}
}
#[must_use]
pub fn content_type(mut self, content_type: MediaType) -> Self {
self.content_type = Some(content_type);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseContract {
pub status: StatusCode,
pub id: ContractId,
pub content_type: MediaType,
}
impl ResponseContract {
pub fn json(status: StatusCode, id: ContractId) -> Self {
Self {
status,
id,
content_type: MediaType::json(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EndpointContracts {
requests: Vec<RequestContract>,
responses: Vec<ResponseContract>,
}
impl EndpointContracts {
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);
}
}
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);
}
}
pub fn requests(&self) -> &[RequestContract] {
&self.requests
}
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,
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OperationDocumentation {
pub summary: Option<String>,
pub description: Option<String>,
pub deprecated: bool,
}
impl OperationDocumentation {
pub fn summary(mut self, summary: impl Into<String>) -> SoapResult<Self> {
self.summary = Some(non_empty("operation summary", summary.into())?);
Ok(self)
}
pub fn description(mut self, description: impl Into<String>) -> SoapResult<Self> {
self.description = Some(non_empty("operation description", description.into())?);
Ok(self)
}
#[must_use]
pub const fn deprecated(mut self) -> Self {
self.deprecated = true;
self
}
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());
}
}