use std::borrow::Cow;
use axum::{
Json,
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
#[derive(Debug)]
pub struct Problem {
status: StatusCode,
typ: &'static str,
detail: Cow<'static, str>,
ext: Option<Box<ProblemExtensions>>,
}
impl std::fmt::Display for Problem {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}: {}", self.typ, self.detail)
}
}
impl std::error::Error for Problem {}
#[derive(Debug, Default)]
struct ProblemExtensions {
identifier: Option<Value>,
subproblems: Vec<Problem>,
extra: serde_json::Map<String, Value>,
}
macro_rules! problems {
($(
$(#[$doc:meta])*
$name:ident => ($status:ident, $urn:literal);
)*) => {
impl Problem {
$(
$(#[$doc])*
pub fn $name(detail: impl Into<Cow<'static, str>>) -> Self {
Self::build(StatusCode::$status, $urn, detail)
}
)*
}
};
}
problems! {
malformed => (BAD_REQUEST, "urn:ietf:params:acme:error:malformed");
bad_nonce => (BAD_REQUEST, "urn:ietf:params:acme:error:badNonce");
unauthorized => (UNAUTHORIZED, "urn:ietf:params:acme:error:unauthorized");
server_internal => (INTERNAL_SERVER_ERROR, "urn:ietf:params:acme:error:serverInternal");
account_does_not_exist => (BAD_REQUEST, "urn:ietf:params:acme:error:accountDoesNotExist");
order_not_ready => (FORBIDDEN, "urn:ietf:params:acme:error:orderNotReady");
bad_csr => (BAD_REQUEST, "urn:ietf:params:acme:error:badCSR");
unsupported_identifier => (BAD_REQUEST, "urn:ietf:params:acme:error:unsupportedIdentifier");
rejected_identifier => (FORBIDDEN, "urn:ietf:params:acme:error:rejectedIdentifier");
access_denied => (FORBIDDEN, "urn:ietf:params:acme:error:unauthorized");
connection => (BAD_REQUEST, "urn:ietf:params:acme:error:connection");
dns => (BAD_REQUEST, "urn:ietf:params:acme:error:dns");
incorrect_response => (FORBIDDEN, "urn:ietf:params:acme:error:incorrectResponse");
tls => (BAD_REQUEST, "urn:ietf:params:acme:error:tls");
external_account_required => (BAD_REQUEST, "urn:ietf:params:acme:error:externalAccountRequired");
already_revoked => (BAD_REQUEST, "urn:ietf:params:acme:error:alreadyRevoked");
bad_revocation_reason => (BAD_REQUEST, "urn:ietf:params:acme:error:badRevocationReason");
key_change_conflict => (CONFLICT, "urn:ietf:params:acme:error:malformed");
unsupported_media_type => (UNSUPPORTED_MEDIA_TYPE, "urn:ietf:params:acme:error:malformed");
payload_too_large => (PAYLOAD_TOO_LARGE, "urn:ietf:params:acme:error:malformed");
service_unavailable => (SERVICE_UNAVAILABLE, "urn:ietf:params:acme:error:serverInternal");
method_not_allowed => (METHOD_NOT_ALLOWED, "urn:ietf:params:acme:error:malformed");
already_replaced => (CONFLICT, "urn:ietf:params:acme:error:alreadyReplaced");
unsupported_contact => (BAD_REQUEST, "urn:ietf:params:acme:error:unsupportedContact");
invalid_contact => (BAD_REQUEST, "urn:ietf:params:acme:error:invalidContact");
user_action_required => (FORBIDDEN, "urn:ietf:params:acme:error:userActionRequired");
rate_limited => (TOO_MANY_REQUESTS, "urn:ietf:params:acme:error:rateLimited");
not_found => (NOT_FOUND, "urn:ietf:params:acme:error:malformed");
}
impl Problem {
fn build(status: StatusCode, typ: &'static str, detail: impl Into<Cow<'static, str>>) -> Self {
Self {
status,
typ,
detail: detail.into(),
ext: None,
}
}
fn ext_mut(&mut self) -> &mut ProblemExtensions {
self.ext.get_or_insert_with(Box::default)
}
pub fn compound(status: StatusCode, detail: impl Into<Cow<'static, str>>) -> Self {
Self::build(status, "urn:ietf:params:acme:error:compound", detail)
}
pub fn bad_signature_algorithm(detail: impl Into<Cow<'static, str>>) -> Self {
Self::build(
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badSignatureAlgorithm",
detail,
)
.with_extra("algorithms", json!(["ES256", "RS256"]))
}
}
impl Problem {
#[must_use]
pub fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn detail(&self) -> &str {
&self.detail
}
#[must_use]
pub fn with_identifier(mut self, identifier: &crate::identifier::Identifier) -> Self {
self.ext_mut().identifier = serde_json::to_value(identifier).ok();
self
}
#[must_use]
pub fn with_subproblems(mut self, subproblems: Vec<Problem>) -> Self {
self.ext_mut().subproblems = subproblems;
self
}
#[must_use]
pub fn with_extra(mut self, key: &str, value: Value) -> Self {
self.ext_mut().extra.insert(key.to_string(), value);
self
}
#[must_use]
pub fn to_value(&self) -> Value {
let mut object = serde_json::Map::new();
object.insert("type".to_string(), Value::String(self.typ.to_string()));
object.insert("detail".to_string(), json!(self.detail));
object.insert("status".to_string(), json!(self.status.as_u16()));
if let Some(ext) = &self.ext {
for (key, value) in &ext.extra {
object.insert(key.clone(), value.clone());
}
if !ext.subproblems.is_empty() {
object.insert(
"subproblems".to_string(),
Value::Array(
ext.subproblems
.iter()
.map(Problem::to_subproblem_value)
.collect(),
),
);
}
}
Value::Object(object)
}
#[must_use]
fn to_subproblem_value(&self) -> Value {
let mut object = serde_json::Map::new();
object.insert("type".to_string(), Value::String(self.typ.to_string()));
object.insert("detail".to_string(), json!(self.detail));
if let Some(identifier) = self.ext.as_ref().and_then(|ext| ext.identifier.as_ref()) {
object.insert("identifier".to_string(), identifier.clone());
}
Value::Object(object)
}
}
impl IntoResponse for Problem {
fn into_response(self) -> Response {
let body = Json(self.to_value());
(
self.status,
[(header::CONTENT_TYPE, "application/problem+json")],
body,
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
async fn assert_problem(problem: Problem, expected_status: u16, expected_type: &str) {
let response = problem.into_response();
assert_eq!(response.status().as_u16(), expected_status);
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["type"], expected_type);
assert_eq!(json["status"], expected_status);
assert_eq!(json["detail"], "boom");
}
#[tokio::test]
async fn malformed_renders_400_problem_json() {
assert_problem(
Problem::malformed("boom"),
400,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn bad_nonce_renders_400_problem_json() {
assert_problem(
Problem::bad_nonce("boom"),
400,
"urn:ietf:params:acme:error:badNonce",
)
.await;
}
#[tokio::test]
async fn unauthorized_renders_401_problem_json() {
assert_problem(
Problem::unauthorized("boom"),
401,
"urn:ietf:params:acme:error:unauthorized",
)
.await;
}
#[tokio::test]
async fn server_internal_renders_500_problem_json() {
assert_problem(
Problem::server_internal("boom"),
500,
"urn:ietf:params:acme:error:serverInternal",
)
.await;
}
#[tokio::test]
async fn account_does_not_exist_renders_400_problem_json() {
assert_problem(
Problem::account_does_not_exist("boom"),
400,
"urn:ietf:params:acme:error:accountDoesNotExist",
)
.await;
}
#[tokio::test]
async fn order_not_ready_renders_403_problem_json() {
assert_problem(
Problem::order_not_ready("boom"),
403,
"urn:ietf:params:acme:error:orderNotReady",
)
.await;
}
#[tokio::test]
async fn bad_csr_renders_400_problem_json() {
assert_problem(
Problem::bad_csr("boom"),
400,
"urn:ietf:params:acme:error:badCSR",
)
.await;
}
#[tokio::test]
async fn unsupported_identifier_renders_400_problem_json() {
assert_problem(
Problem::unsupported_identifier("boom"),
400,
"urn:ietf:params:acme:error:unsupportedIdentifier",
)
.await;
}
#[tokio::test]
async fn rejected_identifier_renders_403_problem_json() {
assert_problem(
Problem::rejected_identifier("boom"),
403,
"urn:ietf:params:acme:error:rejectedIdentifier",
)
.await;
}
#[tokio::test]
async fn access_denied_renders_403_problem_json() {
assert_problem(
Problem::access_denied("boom"),
403,
"urn:ietf:params:acme:error:unauthorized",
)
.await;
}
#[tokio::test]
async fn connection_renders_400_problem_json() {
assert_problem(
Problem::connection("boom"),
400,
"urn:ietf:params:acme:error:connection",
)
.await;
}
#[tokio::test]
async fn dns_renders_400_problem_json() {
assert_problem(Problem::dns("boom"), 400, "urn:ietf:params:acme:error:dns").await;
}
#[tokio::test]
async fn incorrect_response_renders_403_problem_json() {
assert_problem(
Problem::incorrect_response("boom"),
403,
"urn:ietf:params:acme:error:incorrectResponse",
)
.await;
}
#[tokio::test]
async fn tls_renders_400_problem_json() {
assert_problem(Problem::tls("boom"), 400, "urn:ietf:params:acme:error:tls").await;
}
#[tokio::test]
async fn external_account_required_renders_400_problem_json() {
assert_problem(
Problem::external_account_required("boom"),
400,
"urn:ietf:params:acme:error:externalAccountRequired",
)
.await;
}
#[tokio::test]
async fn already_revoked_renders_400_problem_json() {
assert_problem(
Problem::already_revoked("boom"),
400,
"urn:ietf:params:acme:error:alreadyRevoked",
)
.await;
}
#[tokio::test]
async fn bad_revocation_reason_renders_400_problem_json() {
assert_problem(
Problem::bad_revocation_reason("boom"),
400,
"urn:ietf:params:acme:error:badRevocationReason",
)
.await;
}
#[tokio::test]
async fn key_change_conflict_renders_409_problem_json() {
assert_problem(
Problem::key_change_conflict("boom"),
409,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[test]
fn to_value_matches_rendered_body() {
let value = Problem::server_internal("boom").to_value();
assert_eq!(value["type"], "urn:ietf:params:acme:error:serverInternal");
assert_eq!(value["detail"], "boom");
assert_eq!(value["status"], 500);
}
#[test]
fn owned_detail_is_accepted_and_rendered() {
let name = "evil.example.com";
let value = Problem::rejected_identifier(format!("identifier {name} is denied")).to_value();
assert_eq!(value["detail"], "identifier evil.example.com is denied");
}
#[test]
fn detail_is_the_text_without_json_quoting() {
let problem = Problem::unauthorized("not \"yours\"");
assert_eq!(problem.detail(), "not \"yours\"");
assert_ne!(problem.to_value()["detail"].to_string(), problem.detail());
}
}