use super::types::NgsiError;
use axum::http::HeaderMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NgsiFormat {
#[default]
JsonLd,
Json,
GeoJson,
}
impl NgsiFormat {
pub fn mime_type(&self) -> &'static str {
match self {
NgsiFormat::JsonLd => "application/ld+json",
NgsiFormat::Json => "application/json",
NgsiFormat::GeoJson => "application/geo+json",
}
}
pub fn from_media_type(media_type: &str) -> Option<Self> {
let media_type = media_type.split(';').next()?.trim().to_lowercase();
match media_type.as_str() {
"application/ld+json" => Some(NgsiFormat::JsonLd),
"application/json" => Some(NgsiFormat::Json),
"application/geo+json" => Some(NgsiFormat::GeoJson),
"*/*" => Some(NgsiFormat::JsonLd), _ => None,
}
}
pub fn includes_context(&self) -> bool {
matches!(self, NgsiFormat::JsonLd)
}
}
pub struct NgsiContentNegotiator {
supported_formats: Vec<NgsiFormat>,
default_format: NgsiFormat,
}
impl Default for NgsiContentNegotiator {
fn default() -> Self {
Self {
supported_formats: vec![NgsiFormat::JsonLd, NgsiFormat::Json, NgsiFormat::GeoJson],
default_format: NgsiFormat::JsonLd,
}
}
}
impl NgsiContentNegotiator {
pub fn new() -> Self {
Self::default()
}
pub fn negotiate_response(&self, headers: &HeaderMap) -> Result<NgsiFormat, NgsiError> {
let accept = headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/ld+json");
self.parse_accept_header(accept)
}
pub fn validate_content_type(&self, headers: &HeaderMap) -> Result<NgsiFormat, NgsiError> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
NgsiError::UnsupportedMediaType("Content-Type header is required".to_string())
})?;
NgsiFormat::from_media_type(content_type).ok_or_else(|| {
NgsiError::UnsupportedMediaType(format!(
"Unsupported Content-Type: {}. Supported: application/ld+json, application/json",
content_type
))
})
}
fn parse_accept_header(&self, accept: &str) -> Result<NgsiFormat, NgsiError> {
let mut candidates: Vec<(NgsiFormat, f32)> = Vec::new();
for part in accept.split(',') {
let part = part.trim();
let (media_type, quality) = self.parse_accept_part(part);
if let Some(format) = NgsiFormat::from_media_type(media_type) {
candidates.push((format, quality));
}
}
if candidates.is_empty() {
if accept.contains("*/*") || accept.is_empty() {
return Ok(self.default_format);
}
return Err(NgsiError::NotAcceptable(format!(
"No acceptable format found. Requested: {}. Supported: application/ld+json, application/json, application/geo+json",
accept
)));
}
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (format, _) in candidates {
if self.supported_formats.contains(&format) {
return Ok(format);
}
}
Ok(self.default_format)
}
fn parse_accept_part<'a>(&self, part: &'a str) -> (&'a str, f32) {
let mut iter = part.split(';');
let media_type = iter.next().unwrap_or("").trim();
let quality = iter
.find_map(|param| {
param
.trim()
.strip_prefix("q=")
.and_then(|q| q.parse::<f32>().ok())
})
.unwrap_or(1.0);
(media_type, quality)
}
pub fn extract_link_context(&self, headers: &HeaderMap) -> Option<String> {
headers
.get("link")
.and_then(|v| v.to_str().ok())
.and_then(|link| self.parse_link_header(link))
}
fn parse_link_header(&self, link: &str) -> Option<String> {
for part in link.split(',') {
let part = part.trim();
if part.contains("json-ld#context")
|| part.contains("rel=\"http://www.w3.org/ns/json-ld#context\"")
{
if let Some(start) = part.find('<') {
if let Some(end) = part.find('>') {
return Some(part[start + 1..end].to_string());
}
}
}
}
None
}
pub fn extract_tenant(&self, headers: &HeaderMap) -> Option<String> {
headers
.get("ngsild-tenant")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
pub fn build_response_headers(&self, format: NgsiFormat) -> Vec<(&'static str, String)> {
vec![
("Content-Type", format.mime_type().to_string()),
("NGSILD-Version", super::NGSI_LD_VERSION.to_string()),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ngsi_format_from_media_type() {
assert_eq!(
NgsiFormat::from_media_type("application/ld+json"),
Some(NgsiFormat::JsonLd)
);
assert_eq!(
NgsiFormat::from_media_type("application/json"),
Some(NgsiFormat::Json)
);
assert_eq!(
NgsiFormat::from_media_type("application/geo+json"),
Some(NgsiFormat::GeoJson)
);
assert_eq!(NgsiFormat::from_media_type("text/html"), None);
}
#[test]
fn test_ngsi_format_mime_type() {
assert_eq!(NgsiFormat::JsonLd.mime_type(), "application/ld+json");
assert_eq!(NgsiFormat::Json.mime_type(), "application/json");
assert_eq!(NgsiFormat::GeoJson.mime_type(), "application/geo+json");
}
#[test]
fn test_content_negotiator_accept() {
let neg = NgsiContentNegotiator::new();
assert_eq!(
neg.parse_accept_header("application/ld+json").unwrap(),
NgsiFormat::JsonLd
);
assert_eq!(
neg.parse_accept_header("application/json").unwrap(),
NgsiFormat::Json
);
assert_eq!(
neg.parse_accept_header("application/json;q=0.9, application/ld+json;q=1.0")
.unwrap(),
NgsiFormat::JsonLd
);
assert_eq!(neg.parse_accept_header("*/*").unwrap(), NgsiFormat::JsonLd);
}
#[test]
fn test_link_header_parsing() {
let neg = NgsiContentNegotiator::new();
let link = r#"<https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json""#;
let ctx = neg.parse_link_header(link);
assert_eq!(
ctx,
Some("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld".to_string())
);
}
#[test]
fn test_accept_part_parsing() {
let neg = NgsiContentNegotiator::new();
let (media, quality) = neg.parse_accept_part("application/json;q=0.8");
assert_eq!(media, "application/json");
assert!((quality - 0.8).abs() < f32::EPSILON);
let (media, quality) = neg.parse_accept_part("application/ld+json");
assert_eq!(media, "application/ld+json");
assert!((quality - 1.0).abs() < f32::EPSILON);
}
}