use std::sync::Arc;
use axum::extract::rejection::PathRejection;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use crate::App;
use crate::http::error::ApiError;
use crate::pypi::{self, ProjectName};
use crate::store::cache::Representation;
const JSON_TYPES: [&str; 2] = [pypi::render::JSON_CONTENT_TYPE, "application/json"];
const HTML_TYPES: [&str; 2] = ["text/html", "application/vnd.pypi.simple.v1+html"];
fn specificity(range: &str, types: &[&str; 2]) -> u8 {
if range == types[0] {
4
} else if range == types[1] {
3
} else if range == "*/*" {
1
} else if let Some(kind) = range.strip_suffix("/*")
&& types
.iter()
.any(|media| media.split('/').next() == Some(kind))
{
2
} else {
0
}
}
pub async fn index(State(app): State<Arc<App>>, headers: HeaderMap) -> Result<Response, ApiError> {
let _permit = super::admit(&app)?;
let rendered = pypi::index(&app, &negotiate(&headers)?).await?;
Ok(respond(rendered))
}
pub async fn project(
State(app): State<Arc<App>>,
headers: HeaderMap,
path: Result<Path<String>, PathRejection>,
) -> Result<Response, ApiError> {
let Path(project) = path?;
let _permit = super::admit(&app)?;
let name = parse_name(&project)?;
let representation = negotiate(&headers)?;
if !name.is_canonical() {
return Ok(canonical_redirect(&name));
}
let rendered = pypi::serve(&app, &name, representation).await?;
Ok(respond(rendered))
}
pub async fn project_without_slash(
path: Result<Path<String>, PathRejection>,
) -> Result<Response, ApiError> {
let Path(project) = path?;
Ok(canonical_redirect(&parse_name(&project)?))
}
fn parse_name(raw: &str) -> Result<ProjectName, ApiError> {
ProjectName::parse_route(raw).map_err(|err| {
tracing::debug!(error = %err, "refused a project name");
ApiError::InvalidInput("the project name is not a valid PyPI project name")
})
}
fn canonical_redirect(name: &ProjectName) -> Response {
let location = format!("/pypi/simple/{}/", name.as_str());
match HeaderValue::from_str(&location) {
Ok(value) => {
let mut response = StatusCode::MOVED_PERMANENTLY.into_response();
response.headers_mut().insert(header::LOCATION, value);
response
}
Err(_) => ApiError::InvalidInput("the project name cannot be redirected").into_response(),
}
}
fn negotiate(headers: &HeaderMap) -> Result<Representation, ApiError> {
let Some(accept) = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
else {
return Ok(Representation::PypiHtml);
};
let (mut json, mut html) = ((0u8, 0.0f32), (0u8, 0.0f32));
for entry in accept.split(',') {
let mut parts = entry.split(';');
let media = parts.next().unwrap_or_default().trim().to_ascii_lowercase();
let quality = parts
.filter_map(|parameter| {
let (name, value) = parameter.split_once('=')?;
name.trim()
.eq_ignore_ascii_case("q")
.then(|| value.trim().parse::<f32>().ok())
.flatten()
})
.next()
.unwrap_or(1.0);
for (best, types) in [(&mut json, &JSON_TYPES), (&mut html, &HTML_TYPES)] {
let specificity = specificity(&media, types);
if specificity > best.0 {
*best = (specificity, quality);
} else if specificity != 0 && specificity == best.0 {
best.1 = best.1.min(quality);
}
}
}
let (json, html) = (json.1, html.1);
if json <= 0.0 && html <= 0.0 {
return Err(ApiError::NotAcceptable);
}
Ok(if json > html {
Representation::PypiJson
} else {
Representation::PypiHtml
})
}
fn respond(rendered: pypi::Rendered) -> Response {
let mut response = rendered.body.to_vec().into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(rendered.content_type),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
fn accepting(accept: &str) -> Representation {
try_accepting(accept).expect("a representation this server produces")
}
fn try_accepting(accept: &str) -> Result<Representation, ApiError> {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT,
HeaderValue::from_str(accept).expect("a header"),
);
negotiate(&headers)
}
#[test]
fn quality_decides_the_serialisation() {
assert_eq!(
accepting(
"application/vnd.pypi.simple.v1+json, \
application/vnd.pypi.simple.v1+html;q=0.2, text/html;q=0.01"
),
Representation::PypiJson
);
assert_eq!(
accepting("application/vnd.pypi.simple.v1+json;q=0.1, text/html"),
Representation::PypiHtml,
"a low quality on JSON is honoured rather than matched on substring"
);
assert_eq!(accepting("text/html"), Representation::PypiHtml);
assert_eq!(
accepting("*/*"),
Representation::PypiHtml,
"a tie goes to HTML"
);
assert_eq!(
negotiate(&HeaderMap::new()).expect("an absent Accept header means anything"),
Representation::PypiHtml
);
}
#[test]
fn a_representation_this_server_does_not_produce_is_refused() {
assert_eq!(
try_accepting("application/xml"),
Err(ApiError::NotAcceptable)
);
assert_eq!(
try_accepting("text/html;q=0, application/json;q=0"),
Err(ApiError::NotAcceptable),
"`q=0` says a type is not acceptable, so naming one is not accepting it"
);
assert_eq!(
try_accepting("application/xml, text/html;q=0.1"),
Ok(Representation::PypiHtml),
"one acceptable type among several is enough"
);
}
}