use std::{collections::BTreeMap, error::Error as StdError, fmt};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::IF_MATCH};
use baukit_openapi::{INVALID_IF_MATCH_CODE, PRECONDITION_FAILED_CODE, PRECONDITION_REQUIRED_CODE};
use serde_json::Value;
use crate::ApiError;
pub const MAX_ETAG_PREFIX_BYTES: usize = 64;
const WEAK_INDICATOR: &str = "W/";
const QUOTE: u8 = b'"';
const LIST_SEPARATOR: u8 = b',';
const WILDCARD: &str = "*";
const REASON_DETAIL: &str = "reason";
const CURRENT_REVISION_DETAIL: &str = "currentRevision";
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Revision(u64);
impl Revision {
pub const MAX: Self = Self(i64::MAX.unsigned_abs());
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
#[must_use]
pub const fn to_i64(self) -> i64 {
self.0.cast_signed()
}
}
impl From<u32> for Revision {
fn from(value: u32) -> Self {
Self(u64::from(value))
}
}
impl TryFrom<u64> for Revision {
type Error = RevisionOutOfRange;
fn try_from(value: u64) -> Result<Self, Self::Error> {
if value > Self::MAX.0 {
return Err(RevisionOutOfRange);
}
Ok(Self(value))
}
}
impl TryFrom<i64> for Revision {
type Error = RevisionOutOfRange;
fn try_from(value: i64) -> Result<Self, Self::Error> {
u64::try_from(value)
.map(Self)
.map_err(|_| RevisionOutOfRange)
}
}
impl fmt::Display for Revision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RevisionOutOfRange;
impl fmt::Display for RevisionOutOfRange {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("revision must be between 0 and i64::MAX")
}
}
impl StdError for RevisionOutOfRange {}
impl From<RevisionOutOfRange> for ApiError {
fn from(error: RevisionOutOfRange) -> Self {
Self::internal(error)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidEtagPrefix;
impl fmt::Display for InvalidEtagPrefix {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"ETag prefixes must be at most {MAX_ETAG_PREFIX_BYTES} bytes of ASCII letters, digits, \
`.`, `_`, `:`, or `-`"
)
}
}
impl StdError for InvalidEtagPrefix {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RevisionEtag<'a> {
prefix: &'a str,
}
impl<'a> RevisionEtag<'a> {
#[must_use]
pub const fn new(prefix: &'a str) -> Self {
assert!(is_valid_prefix(prefix), "invalid revision ETag prefix");
Self { prefix }
}
pub const fn try_new(prefix: &'a str) -> Result<Self, InvalidEtagPrefix> {
if is_valid_prefix(prefix) {
Ok(Self { prefix })
} else {
Err(InvalidEtagPrefix)
}
}
#[must_use]
pub const fn prefix(&self) -> &'a str {
self.prefix
}
#[must_use]
pub fn format(&self, revision: Revision) -> String {
format!("\"{}{revision}\"", self.prefix)
}
#[must_use]
pub fn header_value(&self, revision: Revision) -> HeaderValue {
HeaderValue::from_str(&self.format(revision))
.expect("a validated prefix and a decimal revision form a valid header value")
}
pub fn parse(&self, value: &HeaderValue) -> Result<Revision, InvalidIfMatch> {
let text = visible_ascii(value.as_bytes())?;
let tag = text.trim_matches([' ', '\t']);
if tag.is_empty() {
return Err(InvalidIfMatch::Empty);
}
if tag.starts_with(WILDCARD) {
return Err(InvalidIfMatch::Wildcard);
}
let (weak, opaque) = single_entity_tag(tag)?;
if weak {
return Err(InvalidIfMatch::WeakValidator);
}
let digits = opaque
.strip_prefix(self.prefix)
.ok_or(InvalidIfMatch::PrefixMismatch)?;
parse_revision(digits)
}
pub fn required_if_match(&self, headers: &HeaderMap) -> Result<Revision, PreconditionError> {
self.optional_if_match(headers)?
.ok_or(PreconditionError::Required)
}
pub fn optional_if_match(
&self,
headers: &HeaderMap,
) -> Result<Option<Revision>, PreconditionError> {
let mut values = headers.get_all(IF_MATCH).iter();
let Some(value) = values.next() else {
return Ok(None);
};
if values.next().is_some() {
return Err(InvalidIfMatch::RepeatedHeader.into());
}
Ok(Some(self.parse(value)?))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvalidIfMatch {
RepeatedHeader,
Empty,
NonAscii,
Wildcard,
WeakValidator,
List,
Malformed,
PrefixMismatch,
InvalidRevision,
RevisionOutOfRange,
}
impl InvalidIfMatch {
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::RepeatedHeader => "repeated_header",
Self::Empty => "empty",
Self::NonAscii => "non_ascii",
Self::Wildcard => "wildcard",
Self::WeakValidator => "weak_validator",
Self::List => "list",
Self::Malformed => "malformed",
Self::PrefixMismatch => "prefix_mismatch",
Self::InvalidRevision => "invalid_revision",
Self::RevisionOutOfRange => "revision_out_of_range",
}
}
}
impl fmt::Display for InvalidIfMatch {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "invalid If-Match: {}", self.reason())
}
}
impl StdError for InvalidIfMatch {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PreconditionError {
Required,
Invalid(InvalidIfMatch),
Stale {
current: Option<Revision>,
},
}
impl PreconditionError {
#[must_use]
pub const fn status(&self) -> StatusCode {
match self {
Self::Required => StatusCode::PRECONDITION_REQUIRED,
Self::Invalid(_) => StatusCode::BAD_REQUEST,
Self::Stale { .. } => StatusCode::PRECONDITION_FAILED,
}
}
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::Required => PRECONDITION_REQUIRED_CODE,
Self::Invalid(_) => INVALID_IF_MATCH_CODE,
Self::Stale { .. } => PRECONDITION_FAILED_CODE,
}
}
const fn message(&self) -> &'static str {
match self {
Self::Required => "If-Match is required",
Self::Invalid(_) => "If-Match must contain one strong ETag for this resource",
Self::Stale { .. } => "The resource changed since it was read",
}
}
fn details(&self) -> BTreeMap<String, Value> {
match self {
Self::Required | Self::Stale { current: None } => BTreeMap::new(),
Self::Invalid(reason) => {
BTreeMap::from([(REASON_DETAIL.to_owned(), Value::from(reason.reason()))])
}
Self::Stale {
current: Some(current),
} => BTreeMap::from([(
CURRENT_REVISION_DETAIL.to_owned(),
Value::from(current.get()),
)]),
}
}
}
impl From<InvalidIfMatch> for PreconditionError {
fn from(error: InvalidIfMatch) -> Self {
Self::Invalid(error)
}
}
impl fmt::Display for PreconditionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{} ({})", self.message(), self.code())
}
}
impl StdError for PreconditionError {}
impl From<PreconditionError> for ApiError {
fn from(error: PreconditionError) -> Self {
Self::new(error.status(), error.code(), error.message()).with_details(error.details())
}
}
pub fn ensure_current_revision(
expected: Revision,
current: Revision,
) -> Result<(), PreconditionError> {
if expected == current {
return Ok(());
}
Err(PreconditionError::Stale {
current: Some(current),
})
}
const fn is_valid_prefix(prefix: &str) -> bool {
let bytes = prefix.as_bytes();
if bytes.len() > MAX_ETAG_PREFIX_BYTES {
return false;
}
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) {
return false;
}
index += 1;
}
true
}
fn visible_ascii(bytes: &[u8]) -> Result<&str, InvalidIfMatch> {
if !bytes
.iter()
.all(|byte| byte.is_ascii_graphic() || matches!(byte, b' ' | b'\t'))
{
return Err(InvalidIfMatch::NonAscii);
}
std::str::from_utf8(bytes).map_err(|_| InvalidIfMatch::NonAscii)
}
fn single_entity_tag(tag: &str) -> Result<(bool, &str), InvalidIfMatch> {
let (weak, quoted) = tag
.strip_prefix(WEAK_INDICATOR)
.map_or((false, tag), |rest| (true, rest));
let body = quoted
.strip_prefix(char::from(QUOTE))
.ok_or(InvalidIfMatch::Malformed)?;
let end = body
.bytes()
.position(|byte| byte == QUOTE)
.ok_or(InvalidIfMatch::Malformed)?;
let (opaque, rest) = (&body[..end], &body[end + 1..]);
let rest = rest.trim_start_matches([' ', '\t']);
if rest.bytes().next() == Some(LIST_SEPARATOR) {
return Err(InvalidIfMatch::List);
}
if !rest.is_empty() || opaque.contains([' ', '\t']) {
return Err(InvalidIfMatch::Malformed);
}
Ok((weak, opaque))
}
fn parse_revision(digits: &str) -> Result<Revision, InvalidIfMatch> {
let canonical = !digits.is_empty()
&& digits.bytes().all(|byte| byte.is_ascii_digit())
&& (digits == "0" || !digits.starts_with('0'));
if !canonical {
return Err(InvalidIfMatch::InvalidRevision);
}
digits
.parse::<u64>()
.ok()
.and_then(|value| Revision::try_from(value).ok())
.ok_or(InvalidIfMatch::RevisionOutOfRange)
}
#[cfg(test)]
mod tests;