vorma 0.86.0-pre.5

Rust-first full-stack web framework: typed views and resources, generated TypeScript contracts, and a Vite-integrated frontend.
Documentation
//! Response finalization: ordered contributions composed into HTTP responses.
//!
//! Header changes are replayed in order: a set replaces every value that
//! the name had so far, and an add appends another field line with its value.
//! A header change that names one of Vorma's protocol headers is a
//! finalization error. A response with a JSON body keeps an application's
//! `content-type` when it is `application/json` or a media type that ends in
//! `+json`, and any other `content-type` on a JSON body is a finalization
//! error. Vorma sets the `content-type` of an HTML document, whatever an
//! application set. HTTP forbids a body with status 204, 205, or 304, so a
//! response that Vorma would send with a body and one of those statuses is a
//! finalization error as well. A `HEAD` response's omitted body is ordinary
//! HTTP handling, not that error. An endpoint's finalization error is answered
//! with the failure envelope of an undeclared 500, which the client reports as
//! an undeclared failure; any other finalization error is answered with a
//! plain-text 500.

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::media_type::is_json_media_type;
use crate::outcome::Contribution;
use crate::outcome::PublicError;
use crate::payload::FailureWire;
use crate::result::{HeaderChange, RedirectStatus};

/// The body of every response that Vorma makes itself.
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)
}

/// A response that Vorma could not assemble.
#[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}")
			}
		}
	}
}

/// A redirect as the response carries it: its `location` and status, and
/// whether the browser runtime follows it itself.
pub(crate) struct ResolvedRedirect {
	pub(crate) location: HeaderValue,
	pub(crate) status: RedirectStatus,
	/// Whether the browser runtime asked to follow the redirect itself: 200 and
	/// headers instead of a 3xx.
	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 = HeaderValue::from_str(&cookie.to_string())
			.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")
}

/// The kind of body that Vorma writes, which decides the `content-type`.
#[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)
}

/// Prevents implicit reuse of request-dependent view data without replacing an
/// authored policy.
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
}

/// A navigation's JSON payload.
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)
}

/// A first load's HTML.
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)
}

/// An endpoint's success: its output as JSON, or, when it
/// declares no output, no body and the header that tells the browser so.
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)
}

/// An endpoint's `304 Not Modified` answer, which has no body and no content
/// type.
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>,
}

/// An endpoint's public failure envelope, with the failure's status.
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,
	})
}

/// A redirect, which carries no body.
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),
	})
}

/// The answer to a navigation from a build that is not the server's.
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
}

/// A plain-text response for a Vorma protocol or execution failure.
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
}

/// Reports a response that Vorma could not assemble and answers 500.
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))
	})
}

/// Reports an endpoint response that Vorma could not assemble and answers with
/// the failure envelope of an undeclared 500, which the client reads as an
/// undeclared failure.
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,
		)
	})
}

/// The readiness answer in development.
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
}