use std::fmt;
use bytes::Bytes;
use http::header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE};
use http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode};
use http_body_util::Full;
use serde::Serialize;
use serde_json::value::RawValue;
use crate::result::{HeaderChange, RedirectStatus};
use crate::{
media_type::is_json_media_type,
outcome::{Contribution, PublicError},
payload::FailureWire,
};
pub(crate) type VormaBody = Full<Bytes>;
pub(crate) const CLIENT_BUILD_ID_HEADER: HeaderName =
HeaderName::from_static("x-vorma-client-build-id");
pub(crate) const BUILD_SKEW_HEADER: HeaderName = HeaderName::from_static("x-vorma-build-skew");
pub(crate) const CLIENT_REDIRECT_HEADER: HeaderName = HeaderName::from_static("x-client-redirect");
pub(crate) const CLIENT_REDIRECT_STATUS_HEADER: HeaderName =
HeaderName::from_static("x-client-redirect-status");
pub(crate) const NO_OUTPUT_HEADER: HeaderName = HeaderName::from_static("x-vorma-no-output");
pub(crate) const ACCEPTS_CLIENT_REDIRECT_HEADER: HeaderName =
HeaderName::from_static("x-accepts-client-redirect");
const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
const HTML_CONTENT_TYPE: &str = "text/html; charset=utf-8";
const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8";
const VIEW_CACHE_CONTROL: &str = "private, max-age=0, must-revalidate, no-cache";
const BUILD_SKEW_BODY: &str = r#"{"ok":true}"#;
fn is_framework_owned(name: &HeaderName) -> bool {
[
&CLIENT_BUILD_ID_HEADER,
&BUILD_SKEW_HEADER,
&CLIENT_REDIRECT_HEADER,
&CLIENT_REDIRECT_STATUS_HEADER,
&NO_OUTPUT_HEADER,
]
.contains(&name)
}
#[derive(Debug)]
pub(crate) enum FinalizeError {
FrameworkOwnedHeader(HeaderName),
BodyForbiddenByStatus(StatusCode),
NonJsonContentType(HeaderValue),
Serialization(serde_json::Error),
}
impl fmt::Display for FinalizeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FrameworkOwnedHeader(name) => write!(
formatter,
"a handler set the {name} header, which Vorma owns"
),
Self::BodyForbiddenByStatus(status) => write!(
formatter,
"the response has a body, which HTTP forbids with status {}",
status.as_u16()
),
Self::NonJsonContentType(value) => write!(
formatter,
"a handler set the content-type header to {:?} on a JSON response, and a JSON response needs application/json or a media type that ends in +json",
String::from_utf8_lossy(value.as_bytes())
),
Self::Serialization(error) => {
write!(formatter, "the response could not be serialized: {error}")
}
}
}
}
pub(crate) struct ResolvedRedirect {
pub(crate) location: HeaderValue,
pub(crate) status: RedirectStatus,
pub(crate) is_followed_by_client: bool,
}
fn replay_headers(
contribution: &Contribution,
client_build_id: &str,
) -> Result<HeaderMap, FinalizeError> {
let mut headers = HeaderMap::new();
for change in &contribution.header_changes {
let (HeaderChange::Set { name, .. } | HeaderChange::Add { name, .. }) = change;
if is_framework_owned(name) {
return Err(FinalizeError::FrameworkOwnedHeader(name.clone()));
}
change.apply_to(&mut headers);
}
for cookie in &contribution.cookies {
let value = primitives_rs::http::cookies::headers::to_set_cookie_header(cookie)
.expect("Vorma checked that the cookie is a valid field value");
headers.append(SET_COOKIE, value);
}
headers.insert(CLIENT_BUILD_ID_HEADER, header_value(client_build_id));
Ok(headers)
}
fn header_value(text: &str) -> HeaderValue {
HeaderValue::from_str(text).expect("Vorma's protocol values are valid field values")
}
#[derive(Clone, Copy)]
enum BodyKind {
Json,
Html,
}
struct Finalization<'a> {
contribution: &'a Contribution,
client_build_id: &'a str,
body: Option<Bytes>,
body_kind: Option<BodyKind>,
redirect: Option<ResolvedRedirect>,
}
fn finalize_with(input: Finalization<'_>) -> Result<Response<VormaBody>, FinalizeError> {
let mut headers = replay_headers(input.contribution, input.client_build_id)?;
let mut status = input.contribution.status.unwrap_or(StatusCode::OK);
let mut body = input.body;
let mut body_kind = input.body_kind;
if let Some(redirect) = input.redirect {
if redirect.is_followed_by_client {
status = StatusCode::OK;
headers.insert(CLIENT_REDIRECT_HEADER, redirect.location);
headers.insert(
CLIENT_REDIRECT_STATUS_HEADER,
header_value(redirect.status.to_status_code().as_str()),
);
} else {
status = redirect.status.to_status_code();
headers.insert(LOCATION, redirect.location);
}
body = None;
body_kind = None;
}
if body.is_some()
&& matches!(
status,
StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED
) {
return Err(FinalizeError::BodyForbiddenByStatus(status));
}
match body_kind {
Some(BodyKind::Json) => {
if let Some(invalid) = headers
.get_all(CONTENT_TYPE)
.iter()
.find(|value| !is_json_media_type(value))
{
return Err(FinalizeError::NonJsonContentType(invalid.clone()));
}
if !headers.contains_key(CONTENT_TYPE) {
headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
}
}
Some(BodyKind::Html) => {
headers.insert(CONTENT_TYPE, HeaderValue::from_static(HTML_CONTENT_TYPE));
}
None => {}
}
let mut response = Response::new(Full::new(body.unwrap_or_default()));
*response.headers_mut() = headers;
*response.status_mut() = status;
Ok(response)
}
fn with_view_cache_control(mut response: Response<VormaBody>) -> Response<VormaBody> {
if !response.headers().contains_key(CACHE_CONTROL) {
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static(VIEW_CACHE_CONTROL));
}
response
}
pub(crate) fn finalize_json(
json: String,
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
finalize_with(Finalization {
contribution,
client_build_id,
body: Some(Bytes::from(json)),
body_kind: Some(BodyKind::Json),
redirect: None,
})
.map(with_view_cache_control)
}
pub(crate) fn finalize_html(
html: String,
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
finalize_with(Finalization {
contribution,
client_build_id,
body: Some(Bytes::from(html)),
body_kind: Some(BodyKind::Html),
redirect: None,
})
.map(with_view_cache_control)
}
pub(crate) fn finalize_api_endpoint_success(
output: Option<&RawValue>,
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
let body = output.map(|output| Bytes::copy_from_slice(output.get().as_bytes()));
let has_body = body.is_some();
let mut response = finalize_with(Finalization {
contribution,
client_build_id,
body,
body_kind: has_body.then_some(BodyKind::Json),
redirect: None,
})?;
if !has_body {
response
.headers_mut()
.insert(NO_OUTPUT_HEADER, HeaderValue::from_static("1"));
}
Ok(response)
}
pub(crate) fn finalize_not_modified(
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
finalize_with(Finalization {
contribution,
client_build_id,
body: None,
body_kind: None,
redirect: None,
})
}
#[derive(Serialize)]
struct EndpointErrorEnvelope<'a> {
failure: FailureWire<'a>,
}
pub(crate) fn finalize_api_endpoint_failure(
status: StatusCode,
public_error: Option<&PublicError>,
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
let envelope = EndpointErrorEnvelope {
failure: FailureWire {
index: None,
status: status.as_u16(),
public_error,
},
};
let body = serde_json::to_vec(&envelope).map_err(FinalizeError::Serialization)?;
finalize_with(Finalization {
contribution,
client_build_id,
body: Some(Bytes::from(body)),
body_kind: Some(BodyKind::Json),
redirect: None,
})
}
pub(crate) fn finalize_redirect(
redirect: ResolvedRedirect,
contribution: &Contribution,
client_build_id: &str,
) -> Result<Response<VormaBody>, FinalizeError> {
finalize_with(Finalization {
contribution,
client_build_id,
body: None,
body_kind: None,
redirect: Some(redirect),
})
}
pub(crate) fn finalize_build_skew(client_build_id: &str) -> Response<VormaBody> {
let mut response = Response::new(Full::new(Bytes::from_static(BUILD_SKEW_BODY.as_bytes())));
let headers = response.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
headers.insert(CLIENT_BUILD_ID_HEADER, header_value(client_build_id));
headers.insert(BUILD_SKEW_HEADER, HeaderValue::from_static("1"));
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
pub(crate) fn finalize_status(
status: StatusCode,
client_build_id: Option<&str>,
) -> Response<VormaBody> {
let text = status.canonical_reason().unwrap_or_default();
let mut response = Response::new(Full::new(Bytes::from(text)));
*response.status_mut() = status;
let headers = response.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(TEXT_CONTENT_TYPE));
if let Some(client_build_id) = client_build_id {
headers.insert(CLIENT_BUILD_ID_HEADER, header_value(client_build_id));
}
response
}
pub(crate) fn finalize_or_report(
result: Result<Response<VormaBody>, FinalizeError>,
client_build_id: &str,
) -> Response<VormaBody> {
result.unwrap_or_else(|error| {
tracing::error!(error = %error, "Vorma could not assemble a response");
finalize_status(StatusCode::INTERNAL_SERVER_ERROR, Some(client_build_id))
})
}
pub(crate) fn finalize_api_endpoint_or_report(
result: Result<Response<VormaBody>, FinalizeError>,
client_build_id: &str,
) -> Response<VormaBody> {
result.unwrap_or_else(|error| {
tracing::error!(error = %error, "Vorma could not assemble a response");
finalize_or_report(
finalize_api_endpoint_failure(
StatusCode::INTERNAL_SERVER_ERROR,
None,
&Contribution {
status: Some(StatusCode::INTERNAL_SERVER_ERROR),
..Contribution::default()
},
client_build_id,
),
client_build_id,
)
})
}
pub(crate) fn finalize_readiness(client_build_id: &str) -> Response<VormaBody> {
let mut response = Response::new(Full::new(Bytes::from_static(b"ok")));
let headers = response.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(TEXT_CONTENT_TYPE));
headers.insert(CLIENT_BUILD_ID_HEADER, header_value(client_build_id));
response
}