use std::convert::Infallible;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use http::header::{HeaderValue, VARY};
use crate::middleware::error_page_filter::{AcceptQualities, accept_qualities};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Html,
Json,
}
#[derive(Debug, Clone, Copy)]
pub struct Negotiate {
qualities: AcceptQualities,
default: Format,
}
impl Negotiate {
#[must_use]
pub const fn default_format(mut self, default: Format) -> Self {
self.default = default;
self
}
#[must_use]
pub fn format(&self) -> Format {
match self.resolve() {
Resolution::Html => Format::Html,
Resolution::Json => Format::Json,
Resolution::NotAcceptable => self.default,
}
}
fn resolve(&self) -> Resolution {
let html_eff = self
.qualities
.html
.or(self.qualities.text_star)
.or(self.qualities.wildcard);
let json_eff = self
.qualities
.json
.or(self.qualities.application_star)
.or(self.qualities.wildcard);
let html_forbidden = matches!(html_eff, Some((q, _)) if q <= 0.0);
let json_forbidden = matches!(json_eff, Some((q, _)) if q <= 0.0);
if html_forbidden && json_forbidden {
return Resolution::NotAcceptable;
}
let html_pos = html_eff.filter(|&(q, _)| q > 0.0);
let json_pos = json_eff.filter(|&(q, _)| q > 0.0);
match (html_pos, json_pos) {
(Some((hq, hidx)), Some((jq, jidx))) => {
if (hq - jq).abs() < f32::EPSILON {
match hidx.cmp(&jidx) {
std::cmp::Ordering::Less => Resolution::Html,
std::cmp::Ordering::Greater => Resolution::Json,
std::cmp::Ordering::Equal => {
self.default_resolution(html_forbidden, json_forbidden)
}
}
} else if hq > jq {
Resolution::Html
} else {
Resolution::Json
}
}
(Some(_), None) => Resolution::Html,
(None, Some(_)) => Resolution::Json,
(None, None) => self.default_resolution(html_forbidden, json_forbidden),
}
}
const fn default_resolution(&self, html_forbidden: bool, json_forbidden: bool) -> Resolution {
match self.default {
Format::Html if html_forbidden => Resolution::Json,
Format::Html => Resolution::Html,
Format::Json if json_forbidden => Resolution::Html,
Format::Json => Resolution::Json,
}
}
#[must_use]
pub fn respond<F, J>(self, html: F, json: J) -> Negotiated<F, J>
where
F: FnOnce() -> maud::Markup,
J: serde::Serialize,
{
Negotiated {
resolution: self.resolve(),
html,
json,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Resolution {
Html,
Json,
NotAcceptable,
}
impl<S> FromRequestParts<S> for Negotiate
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self {
qualities: accept_qualities(&parts.headers),
default: Format::Html,
})
}
}
pub struct Negotiated<F, J> {
resolution: Resolution,
html: F,
json: J,
}
impl<F, J> IntoResponse for Negotiated<F, J>
where
F: FnOnce() -> maud::Markup,
J: serde::Serialize,
{
fn into_response(self) -> Response {
let mut response = match self.resolution {
Resolution::Html => (self.html)().into_response(),
Resolution::Json => axum::Json(self.json).into_response(),
Resolution::NotAcceptable => (
http::StatusCode::NOT_ACCEPTABLE,
"406 Not Acceptable: no acceptable representation for this resource",
)
.into_response(),
};
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Accept"));
response
}
}
#[cfg(test)]
mod tests {
use super::*;
fn negotiate(accept: Option<&str>) -> Negotiate {
let mut headers = http::HeaderMap::new();
if let Some(value) = accept {
headers.insert(http::header::ACCEPT, HeaderValue::from_str(value).unwrap());
}
Negotiate {
qualities: accept_qualities(&headers),
default: Format::Html,
}
}
#[test]
fn wildcard_q_beats_demoted_html() {
assert_eq!(
negotiate(Some("text/html;q=0.1, */*;q=1")).format(),
Format::Json,
);
}
#[test]
fn html_wins_on_higher_q() {
assert_eq!(
negotiate(Some("application/json;q=0.9, text/html;q=1.0")).format(),
Format::Html,
);
}
#[test]
fn explicit_html_ties_wildcard_earlier_index_wins() {
assert_eq!(
negotiate(Some("text/html;q=1, */*;q=1")).format(),
Format::Html,
);
}
#[test]
fn explicit_json_beats_demoted_wildcard() {
assert_eq!(
negotiate(Some("application/json, */*;q=0.1")).format(),
Format::Json,
);
}
#[test]
fn bare_wildcard_uses_default() {
assert_eq!(negotiate(Some("*/*")).format(), Format::Html);
assert_eq!(
negotiate(Some("*/*")).default_format(Format::Json).format(),
Format::Json,
);
}
#[test]
fn missing_accept_uses_default() {
assert_eq!(negotiate(None).format(), Format::Html);
assert_eq!(
negotiate(None).default_format(Format::Json).format(),
Format::Json,
);
}
#[test]
fn text_star_forbidden_falls_through_to_wildcard_json() {
assert_eq!(
negotiate(Some("text/*;q=0, */*;q=1")).format(),
Format::Json,
);
}
#[test]
fn application_star_forbidden_not_resurrected_by_json_default() {
assert_eq!(
negotiate(Some("application/*;q=0"))
.default_format(Format::Json)
.format(),
Format::Html,
);
}
#[test]
fn text_star_positive_serves_html() {
assert_eq!(negotiate(Some("text/*;q=1")).format(), Format::Html);
}
#[test]
fn application_star_positive_serves_json() {
assert_eq!(negotiate(Some("application/*;q=1")).format(), Format::Json);
}
#[test]
fn concrete_type_beats_type_wildcard_forbidding_html() {
assert_eq!(
negotiate(Some("text/html;q=0, text/*;q=1")).resolve(),
Resolution::Json,
);
assert_eq!(
negotiate(Some("text/html;q=0, text/*;q=1")).format(),
Format::Json,
);
}
#[test]
fn forbidden_html_falls_through_to_wildcard_json() {
assert_eq!(
negotiate(Some("text/html;q=0, */*;q=1")).resolve(),
Resolution::Json,
);
}
#[test]
fn forbidden_json_not_resurrected_by_default() {
assert_eq!(
negotiate(Some("application/json;q=0"))
.default_format(Format::Json)
.resolve(),
Resolution::Html,
);
}
#[test]
fn both_formats_forbidden_is_not_acceptable() {
assert_eq!(
negotiate(Some("text/html;q=0, application/json;q=0")).resolve(),
Resolution::NotAcceptable,
);
}
#[test]
fn wildcard_forbidden_is_not_acceptable() {
assert_eq!(
negotiate(Some("*/*;q=0")).resolve(),
Resolution::NotAcceptable
);
assert_eq!(
negotiate(Some("*/*;q=0"))
.default_format(Format::Json)
.resolve(),
Resolution::NotAcceptable,
);
}
#[test]
fn unlisted_type_is_not_an_exclusion() {
assert_eq!(
negotiate(Some("application/xml")).resolve(),
Resolution::Html,
);
assert_eq!(
negotiate(Some("application/xml"))
.default_format(Format::Json)
.resolve(),
Resolution::Json,
);
}
#[test]
fn demoted_but_positive_html_still_loses_to_wildcard() {
assert_eq!(
negotiate(Some("text/html;q=0.1, */*;q=1")).resolve(),
Resolution::Json,
);
}
#[test]
fn problem_json_alone_uses_html_default() {
assert_eq!(
negotiate(Some("application/problem+json")).resolve(),
Resolution::Html,
);
assert_eq!(
negotiate(Some("application/problem+json")).format(),
Format::Html,
);
}
#[test]
fn problem_json_alone_uses_json_default_via_default_not_match() {
assert_eq!(
negotiate(Some("application/problem+json"))
.default_format(Format::Json)
.resolve(),
Resolution::Json,
);
assert_eq!(
negotiate(Some("application/problem+json"))
.default_format(Format::Json)
.format(),
Format::Json,
);
}
#[test]
fn plain_json_serves_json() {
assert_eq!(
negotiate(Some("application/json")).resolve(),
Resolution::Json,
);
assert_eq!(negotiate(Some("application/json")).format(), Format::Json);
}
#[test]
fn json_and_problem_json_serves_json() {
assert_eq!(
negotiate(Some("application/json, application/problem+json")).resolve(),
Resolution::Json,
);
assert_eq!(
negotiate(Some("application/json, application/problem+json")).format(),
Format::Json,
);
}
#[test]
fn problem_json_with_html_serves_html() {
assert_eq!(
negotiate(Some("application/problem+json, text/html")).resolve(),
Resolution::Html,
);
assert_eq!(
negotiate(Some("application/problem+json, text/html")).format(),
Format::Html,
);
}
#[test]
fn resolve_matches_format_for_non_forbidden_cases() {
for accept in [
Some("text/html"),
Some("application/json"),
Some("application/json;q=0.9, text/html;q=1.0"),
Some("text/html;q=1, */*;q=1"),
Some("*/*"),
None,
] {
let n = negotiate(accept);
let expected = match n.resolve() {
Resolution::Html => Format::Html,
Resolution::Json => Format::Json,
Resolution::NotAcceptable => unreachable!("no forbidden case here"),
};
assert_eq!(n.format(), expected, "accept={accept:?}");
}
}
}