use std::fmt;
use std::sync::OnceLock;
use regex::Regex;
use crate::error::{Error, Result};
const UUID_PATTERN: &str = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
fn uuid_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(&format!("^{UUID_PATTERN}$")).expect("valid uuid regex"))
}
fn content_type_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?x)
^\s*
application/
(?:x-(?<domain>[\w]+)\+xml|(?<mime>[\w.+-]+))
\s*;\s*version=(?<version>[^;\s]+)
(?:\s*;\s*type=(?<type>[\w]+))?
\s*$",
)
.expect("valid content type regex")
})
}
fn qualified_type_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^\s*(?<domain>[a-zA-Z]+)(?<version>[\d]+)\.(?<type>[\w]+)\s*$")
.expect("valid qualified type regex")
})
}
const QUOTED_PATTERN: &str = r"[^']*?(?:''[^']*?)*";
fn etp_uri_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(&format!(
r"(?x)
^\s*eml:///
(?:dataspace\('(?<dataspace>{QUOTED_PATTERN})'\)/?)?
(?:
(?<domain>[a-zA-Z]+)(?<version>[\d]+)
\.(?<type>[\w]+)
\((?:uuid=)?(?<uuid>{UUID_PATTERN})(?:,version='(?<objversion>{QUOTED_PATTERN})')?\)
(?:/(?<rest>.*))?
)?
\s*$"
))
.expect("valid ETP URI regex")
})
}
fn canonical_etp_uri_res() -> &'static [Regex; 4] {
static RES: OnceLock<[Regex; 4]> = OnceLock::new();
RES.get_or_init(|| {
let dataspace = format!(r"(?:dataspace\('{QUOTED_PATTERN}'\)/)?");
let object = format!(
r"(?:witsml|resqml|prodml|eml)[1-9]\d\.\w+\((?:{UUID_PATTERN}|uuid={UUID_PATTERN},version='{QUOTED_PATTERN}')\)"
);
let collection = r"(?:witsml|resqml|prodml|eml)[1-9]\d\.\w+(?:\?[^#]+)?";
[
format!(r"^eml:///(?:dataspace\('{QUOTED_PATTERN}'\))?$"),
format!(r"^eml:///{dataspace}{object}$"),
format!(r"^eml:///{dataspace}{object}/{collection}$"),
format!(r"^eml:///{dataspace}{collection}$"),
]
.map(|pattern| Regex::new(&pattern).expect("valid canonical ETP URI regex"))
})
}
pub fn is_uuid(value: &str) -> bool {
uuid_re().is_match(value.trim())
}
pub fn normalize_uuid(value: &str) -> Result<String> {
let trimmed = value.trim();
if is_uuid(trimmed) {
Ok(trimmed.to_ascii_lowercase())
} else {
Err(Error::parse(trimmed, "uuid", "not a UUID"))
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ContentType {
pub domain: String,
pub version: String,
pub object_type: Option<String>,
pub raw_mime: Option<String>,
}
impl ContentType {
pub fn parse(value: &str) -> Result<Self> {
let caps = content_type_re()
.captures(value)
.ok_or_else(|| Error::parse(value, "contentType", "does not match the Energistics form"))?;
Ok(ContentType {
domain: caps
.name("domain")
.map(|m| m.as_str().to_string())
.unwrap_or_default(),
version: caps["version"].to_string(),
object_type: caps.name("type").map(|m| m.as_str().to_string()),
raw_mime: caps.name("mime").map(|m| m.as_str().to_string()),
})
}
pub fn type_key(&self) -> Option<&str> {
self.object_type.as_deref().map(crate::naming::strip_obj_prefix)
}
pub fn of_object(domain: &str, version: &str, object_type: &str) -> String {
format!("application/x-{domain}+xml;version={version};type={object_type}")
}
}
impl fmt::Display for ContentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.raw_mime, &self.object_type) {
(Some(mime), _) => write!(f, "application/{};version={}", mime, self.version),
(None, Some(ty)) => {
write!(
f,
"application/x-{}+xml;version={};type={}",
self.domain, self.version, ty
)
}
(None, None) => write!(f, "application/x-{}+xml;version={}", self.domain, self.version),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct QualifiedType {
pub domain: String,
pub version: String,
pub object_type: String,
}
impl QualifiedType {
pub fn parse(value: &str) -> Result<Self> {
let caps = qualified_type_re()
.captures(value)
.ok_or_else(|| Error::parse(value, "qualifiedType", "expected `<domain><version>.<Type>`"))?;
Ok(QualifiedType {
domain: caps["domain"].to_string(),
version: caps["version"].to_string(),
object_type: caps["type"].to_string(),
})
}
pub fn type_key(&self) -> &str {
crate::naming::strip_obj_prefix(&self.object_type)
}
pub fn dotted_version(&self) -> String {
self.version
.chars()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(".")
}
}
impl fmt::Display for QualifiedType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}.{}", self.domain, self.version, self.object_type)
}
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct EtpUri {
pub dataspace: Option<String>,
pub domain: Option<String>,
pub version: Option<String>,
pub object_type: Option<String>,
pub uuid: Option<String>,
pub object_version: Option<String>,
pub rest: Option<String>,
}
impl EtpUri {
pub fn parse(value: &str) -> Result<Self> {
let caps = etp_uri_re()
.captures(value)
.ok_or_else(|| Error::parse(value, "ETP URI", "does not match the ETP 1.2 grammar"))?;
Ok(EtpUri {
dataspace: caps.name("dataspace").map(|m| m.as_str().to_string()),
domain: caps.name("domain").map(|m| m.as_str().to_string()),
version: caps.name("version").map(|m| m.as_str().to_string()),
object_type: caps.name("type").map(|m| m.as_str().to_string()),
uuid: caps.name("uuid").map(|m| m.as_str().to_ascii_lowercase()),
object_version: caps.name("objversion").map(|m| m.as_str().replace("''", "'")),
rest: caps
.name("rest")
.map(|m| m.as_str().to_string())
.filter(|s| !s.is_empty()),
})
}
pub fn is_canonical(value: &str) -> bool {
canonical_etp_uri_res().iter().any(|re| re.is_match(value))
}
pub fn is_dataspace(&self) -> bool {
self.uuid.is_none()
}
pub fn qualified_type(&self) -> Option<QualifiedType> {
Some(QualifiedType {
domain: self.domain.clone()?,
version: self.version.clone()?,
object_type: self.object_type.clone()?,
})
}
pub fn object(dataspace: Option<&str>, qualified_type: &str, uuid: &str) -> String {
Self::object_versioned(dataspace, qualified_type, uuid, None)
}
pub fn object_versioned(
dataspace: Option<&str>,
qualified_type: &str,
uuid: &str,
object_version: Option<&str>,
) -> String {
let name = match object_version {
Some(version) => {
format!(
"{qualified_type}(uuid={uuid},version='{}')",
version.replace('\'', "''")
)
}
None => format!("{qualified_type}({uuid})"),
};
match dataspace {
Some(ds) => format!("eml:///dataspace('{ds}')/{name}"),
None => format!("eml:///{name}"),
}
}
}
impl fmt::Display for EtpUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "eml:///")?;
if let Some(ds) = &self.dataspace {
write!(f, "dataspace('{ds}')")?;
if self.uuid.is_some() {
write!(f, "/")?;
}
}
if let (Some(d), Some(v), Some(t), Some(u)) =
(&self.domain, &self.version, &self.object_type, &self.uuid)
{
match &self.object_version {
Some(ov) => write!(f, "{d}{v}.{t}(uuid={u},version='{}')", ov.replace('\'', "''"))?,
None => write!(f, "{d}{v}.{t}({u})")?,
}
if let Some(rest) = &self.rest {
write!(f, "/{rest}")?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuids() {
assert!(is_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
assert!(!is_uuid("not-a-uuid"));
assert_eq!(
normalize_uuid(" A1B2C3D4-E5F6-7890-ABCD-EF1234567890 ").unwrap(),
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
);
}
#[test]
fn content_types() {
let ct =
ContentType::parse("application/x-resqml+xml;version=2.2;type=TriangulatedSetRepresentation")
.unwrap();
assert_eq!(ct.domain, "resqml");
assert_eq!(ct.version, "2.2");
assert_eq!(ct.object_type.as_deref(), Some("TriangulatedSetRepresentation"));
let raw = "application/x-resqml+xml;version=2.0;type=obj_TriangulatedSetRepresentation";
let old = ContentType::parse(raw).unwrap();
assert_eq!(
old.object_type.as_deref(),
Some("obj_TriangulatedSetRepresentation")
);
assert_eq!(
old.to_string(),
raw,
"a parsed content type must round trip verbatim"
);
assert_eq!(old.type_key(), Some("TriangulatedSetRepresentation"));
let h5 = ContentType::parse("application/x-hdf5;version=1.0").unwrap();
assert_eq!(h5.raw_mime.as_deref(), Some("x-hdf5"));
}
#[test]
fn qualified_types() {
let qt = QualifiedType::parse("resqml22.TriangulatedSetRepresentation").unwrap();
assert_eq!(qt.domain, "resqml");
assert_eq!(qt.version, "22");
assert_eq!(qt.dotted_version(), "2.2");
assert_eq!(qt.to_string(), "resqml22.TriangulatedSetRepresentation");
}
#[test]
fn etp_uris() {
let uri = EtpUri::parse(
"eml:///dataspace('demo/ds')/resqml22.TriangulatedSetRepresentation(a1b2c3d4-e5f6-7890-abcd-ef1234567890)",
)
.unwrap();
assert_eq!(uri.dataspace.as_deref(), Some("demo/ds"));
assert_eq!(uri.object_type.as_deref(), Some("TriangulatedSetRepresentation"));
assert!(!uri.is_dataspace());
let ds = EtpUri::parse("eml:///dataspace('demo')").unwrap();
assert!(ds.is_dataspace());
let no_ds = EtpUri::parse("eml:///witsml21.Well(a1b2c3d4-e5f6-7890-abcd-ef1234567890)").unwrap();
assert_eq!(no_ds.dataspace, None);
assert_eq!(no_ds.domain.as_deref(), Some("witsml"));
assert_eq!(
uri.to_string(),
"eml:///dataspace('demo/ds')/resqml22.TriangulatedSetRepresentation(a1b2c3d4-e5f6-7890-abcd-ef1234567890)"
);
}
#[test]
fn a_versioned_object_uri_reads_and_writes_back() {
let text = "eml:///dataspace('rdms-db')/resqml20.obj_HorizonInterpretation(uuid=421a7a05-033a-450d-bcef-051352023578,version='2.0')";
let uri = EtpUri::parse(text).unwrap();
assert_eq!(uri.uuid.as_deref(), Some("421a7a05-033a-450d-bcef-051352023578"));
assert_eq!(uri.object_version.as_deref(), Some("2.0"));
assert_eq!(
uri.version.as_deref(),
Some("20"),
"the schema version is another field"
);
assert_eq!(uri.rest, None);
assert_eq!(uri.to_string(), text);
assert!(EtpUri::is_canonical(text));
let alternate =
"eml:///resqml20.obj_HorizonInterpretation(uuid=421a7a05-033a-450d-bcef-051352023578)";
assert!(EtpUri::parse(alternate).is_ok());
assert!(!EtpUri::is_canonical(alternate));
}
}