use std::future::{IntoFuture, Ready, ready};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use actix_web::body::BoxBody;
use actix_web::dev::Payload;
use actix_web::http::{StatusCode, Uri, header};
use actix_web::{FromRequest, HttpRequest, HttpResponse, Responder, web};
use serde::Serialize;
use tokio_stream::{Stream, StreamExt};
#[allow(unused_imports)]
pub use crate::http_protocol::{
BrowserStreamEvent, PAGE_LOCATION_HEADER, PAGE_VERSION_HEADER, PAGE_VISIT_HEADER,
PARTIAL_COMPONENT_HEADER, PARTIAL_KIND_HEADER, PARTIAL_ONLY_HEADER, PARTIAL_RESPONSE_HEADER,
PageProtocol, PartialPageProtocol, PartialReloadEntry, PartialReloadKind, PartialReloadRequest,
Redirect, ValidationErrors, page_version, partial_reload_request, wants_page_protocol,
};
use crate::local_renderer::RendererState;
use crate::renderer::{PageRoute, RenderedPage, RenderedPageStream};
use crate::{HeadTag, RendererError};
#[derive(Clone)]
pub struct Renderer {
renderer: RendererState,
request: HttpRequest,
request_context: RequestContext,
}
#[derive(Clone, Copy)]
enum RendererMode {
Buffered,
Streaming,
}
#[derive(Clone)]
struct RequestContext {
page_url: String,
is_page_protocol: bool,
request_version: Option<String>,
partial_reload: Option<PartialReloadRequest>,
}
pub struct RendererBuilder {
renderer: RendererState,
route: PageRoute,
request_context: RequestContext,
mode: RendererMode,
pending_error: Option<RendererError>,
validation_errors: Option<Result<serde_json::Value, RendererError>>,
}
impl Renderer {
pub fn uri(&self) -> &Uri {
self.request.uri()
}
pub fn headers(&self) -> &actix_web::http::header::HeaderMap {
self.request.headers()
}
pub fn is_visit(&self) -> bool {
self.request_context.is_page_protocol
}
pub fn partial_reload(&self) -> Option<PartialReloadRequest> {
self.request_context.partial_reload.clone()
}
pub fn redirect(&self, location: impl Into<String>) -> Redirect {
Redirect::to(location)
}
pub fn hard_redirect(&self, location: impl Into<String>) -> Redirect {
Redirect::hard(location)
}
pub fn render(&self, page: impl Into<String>) -> RendererBuilder {
self.builder(page, RendererMode::Buffered)
}
pub fn stream(&self, page: impl Into<String>) -> RendererBuilder {
self.builder(page, RendererMode::Streaming)
}
fn builder(&self, page: impl Into<String>, mode: RendererMode) -> RendererBuilder {
RendererBuilder {
renderer: self.renderer.clone(),
route: self
.renderer
.route(page.into(), self.request_context.page_url.clone()),
request_context: self.request_context.clone(),
mode,
pending_error: None,
validation_errors: None,
}
}
}
impl RendererBuilder {
pub fn url(mut self, url: impl Into<String>) -> Self {
self.route.page.url = url.into();
self
}
pub fn props<T>(mut self, props: T) -> Self
where
T: Serialize,
{
match self.route.set_props(props) {
Ok(()) => {}
Err(error) => self.pending_error = Some(error),
}
self
}
pub fn locale(mut self, locale: impl Into<String>) -> Self {
self.route = self.route.locale(locale);
self
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.route = self.route.version(version);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.route = self.route.title(title);
self
}
pub fn resources<T>(mut self, resources: T) -> Self
where
T: Serialize,
{
match self.route.set_resources(resources) {
Ok(()) => {}
Err(error) => self.pending_error = Some(error),
}
self
}
pub fn context<T>(mut self, context: T) -> Self
where
T: Serialize,
{
match self.route.set_context(context) {
Ok(()) => {}
Err(error) => self.pending_error = Some(error),
}
self
}
pub fn head(mut self, head: Vec<HeadTag>) -> Self {
self.route = self.route.head(head);
self
}
pub fn errors<T>(mut self, errors: T) -> Self
where
T: Serialize,
{
self.validation_errors = Some(serde_json::to_value(errors).map_err(RendererError::from));
self
}
}
impl IntoFuture for RendererBuilder {
type Output = HttpResponse;
type IntoFuture = Pin<Box<dyn std::future::Future<Output = HttpResponse>>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
if let Some(error) = self.pending_error {
return error.into_actix_response_for_visit(self.request_context.is_page_protocol);
}
if let Some(errors) = self.validation_errors {
return match errors {
Ok(errors) => {
validation_response(
self.renderer,
self.route,
errors,
&self.request_context,
)
.await
}
Err(error) => {
error.into_actix_response_for_visit(self.request_context.is_page_protocol)
}
};
}
match self.mode {
RendererMode::Buffered => {
buffered_response(self.renderer, self.route, &self.request_context).await
}
RendererMode::Streaming => {
streaming_response(self.renderer, self.route, &self.request_context).await
}
}
})
}
}
impl FromRequest for Renderer {
type Error = actix_web::Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let renderer = req
.app_data::<web::Data<RendererState>>()
.map(|renderer| renderer.get_ref().clone())
.ok_or_else(|| actix_web::error::ErrorInternalServerError("missing RendererState"));
ready(renderer.map(|renderer| Self {
renderer,
request: req.clone(),
request_context: RequestContext {
page_url: uri_to_page_url(req.uri()),
is_page_protocol: actix_wants_page_protocol(req.headers()),
request_version: actix_page_version(req.headers()).map(str::to_owned),
partial_reload: actix_partial_reload_request(req.headers()),
},
}))
}
}
pub fn hard_redirect_response(location: &str) -> HttpResponse {
HttpResponse::Conflict()
.insert_header((PAGE_LOCATION_HEADER, location.to_owned()))
.finish()
}
pub fn version_mismatch_response(location: &str) -> HttpResponse {
HttpResponse::Conflict()
.insert_header((PAGE_LOCATION_HEADER, location.to_owned()))
.insert_header((header::VARY, PAGE_VERSION_HEADER))
.finish()
}
pub fn prerendered_page_path(
output_dir: impl AsRef<Path>,
url: &str,
) -> Result<PathBuf, crate::RendererError> {
let path = url
.split_once('#')
.map(|(value, _)| value)
.unwrap_or(url)
.split_once('?')
.map(|(value, _)| value)
.unwrap_or(url)
.trim();
if path.is_empty() || !path.starts_with('/') {
return Err(crate::RendererError::PrerenderFailed(format!(
"prerender URLs must start with '/': {url}"
)));
}
let relative = path.trim_start_matches('/');
let mut file_path = output_dir.as_ref().to_path_buf();
if relative.is_empty() {
file_path.push("index.html");
return Ok(file_path);
}
for segment in relative.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(crate::RendererError::PrerenderFailed(format!(
"invalid prerender URL segment in {url}"
)));
}
file_path.push(segment);
}
file_path.push("index.html");
Ok(file_path)
}
pub async fn serve_prerendered_page(
output_dir: impl AsRef<Path>,
url: &str,
) -> Result<Option<HttpResponse>, crate::RendererError> {
let file_path = prerendered_page_path(output_dir, url)?;
match tokio::fs::read_to_string(&file_path).await {
Ok(html) => Ok(Some(
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html),
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn ndjson_stream_response<S, T, E>(stream: S) -> HttpResponse
where
S: Stream<Item = Result<BrowserStreamEvent<T>, E>> + 'static,
T: Serialize,
E: std::fmt::Display,
{
let body_stream = stream.map(|event| {
let mut line = match event {
Ok(event) => serde_json::to_string(&event).unwrap_or_else(|error| {
serde_json::json!({
"type": "error",
"message": error.to_string(),
})
.to_string()
}),
Err(error) => serde_json::json!({
"type": "error",
"message": error.to_string(),
})
.to_string(),
};
line.push('\n');
Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(line))
});
HttpResponse::Ok()
.content_type("application/x-ndjson; charset=utf-8")
.streaming(body_stream)
}
impl Responder for Redirect {
type Body = BoxBody;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
if self.hard {
return hard_redirect_response(self.location());
}
HttpResponse::build(
actix_web::http::StatusCode::from_u16(self.status.as_u16())
.unwrap_or(actix_web::http::StatusCode::SEE_OTHER),
)
.insert_header((header::LOCATION, self.location))
.finish()
}
}
impl Responder for RenderedPage {
type Body = BoxBody;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
page_response(self)
}
}
impl Responder for RenderedPageStream {
type Body = BoxBody;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
stream_response(self)
}
}
impl Responder for RendererError {
type Body = BoxBody;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
self.into_actix_response_for_headers(None)
}
}
async fn buffered_response(
renderer: RendererState,
route: PageRoute,
request_context: &RequestContext,
) -> HttpResponse {
match protocol_route_or_response(&renderer, route, request_context) {
Ok(route) => match renderer.render_route(route).await {
Ok(page) => page_response(page),
Err(error) => error.into_actix_response_for_visit(request_context.is_page_protocol),
},
Err(response) => response,
}
}
async fn streaming_response(
renderer: RendererState,
route: PageRoute,
request_context: &RequestContext,
) -> HttpResponse {
match protocol_route_or_response(&renderer, route, request_context) {
Ok(route) => match renderer.stream_route(route).await {
Ok(page) => stream_response(page),
Err(error) => error.into_actix_response_for_visit(request_context.is_page_protocol),
},
Err(response) => response,
}
}
async fn validation_response(
renderer: RendererState,
route: PageRoute,
errors: serde_json::Value,
request_context: &RequestContext,
) -> HttpResponse {
let mut envelope = renderer.page_envelope_with_meta(route.page, route.meta);
envelope.errors = errors;
if request_context.is_page_protocol {
return validation_errors_response(envelope);
}
match renderer.render_envelope(envelope).await {
Ok(page) => HttpResponse::build(StatusCode::UNPROCESSABLE_ENTITY)
.content_type("text/html; charset=utf-8")
.body(page.html),
Err(error) => error.into_actix_response_for_visit(false),
}
}
fn protocol_route_or_response(
renderer: &RendererState,
route: PageRoute,
request_context: &RequestContext,
) -> Result<PageRoute, HttpResponse> {
if request_context.is_page_protocol {
if let Some(request_version) = request_context.request_version.as_deref() {
if request_version != renderer.version() {
return Err(version_mismatch_response(&route.page.url));
}
}
}
if let Some(partial) = request_context.partial_reload.clone() {
if partial.component == route.page.component {
return Err(partial_page_protocol_response(
renderer.partial_page_envelope(&route, partial),
));
}
}
if request_context.is_page_protocol {
return Err(page_protocol_response(
renderer.page_envelope_with_meta(route.page, route.meta),
));
}
Ok(route)
}
fn page_protocol_response(envelope: crate::PageEnvelope) -> HttpResponse {
HttpResponse::Ok()
.insert_header((header::VARY, PAGE_VISIT_HEADER))
.json(envelope)
}
fn validation_errors_response(envelope: crate::PageEnvelope) -> HttpResponse {
HttpResponse::build(StatusCode::UNPROCESSABLE_ENTITY)
.insert_header((header::VARY, PAGE_VISIT_HEADER))
.json(envelope)
}
fn partial_page_protocol_response(envelope: crate::PartialPageEnvelope) -> HttpResponse {
HttpResponse::Ok()
.insert_header((
header::VARY,
"x-haven-visit, x-haven-partial-component, x-haven-partial-only, x-haven-partial-kind",
))
.insert_header((PARTIAL_RESPONSE_HEADER, "true"))
.json(envelope)
}
fn page_response(page: RenderedPage) -> HttpResponse {
let status = page
.status
.and_then(|code| StatusCode::from_u16(code).ok())
.unwrap_or(StatusCode::OK);
HttpResponse::build(status)
.content_type("text/html; charset=utf-8")
.body(page.html)
}
fn stream_response(page: RenderedPageStream) -> HttpResponse {
let status = page
.status
.and_then(|code| StatusCode::from_u16(code).ok())
.unwrap_or(StatusCode::OK);
HttpResponse::build(status)
.content_type("text/html; charset=utf-8")
.streaming(page.stream)
}
fn uri_to_page_url(uri: &Uri) -> String {
uri.path_and_query()
.map(|value| value.as_str().to_owned())
.unwrap_or_else(|| uri.path().to_owned())
}
fn actix_wants_page_protocol(headers: &actix_web::http::header::HeaderMap) -> bool {
actix_header_value(headers, PAGE_VISIT_HEADER)
.map(|value| value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
fn actix_page_version(headers: &actix_web::http::header::HeaderMap) -> Option<&str> {
actix_header_value(headers, PAGE_VERSION_HEADER)
}
fn actix_partial_reload_request(
headers: &actix_web::http::header::HeaderMap,
) -> Option<PartialReloadRequest> {
if !actix_wants_page_protocol(headers) {
return None;
}
let component = actix_header_value(headers, PARTIAL_COMPONENT_HEADER)?.trim();
if component.is_empty() {
return None;
}
let only = actix_header_value(headers, PARTIAL_ONLY_HEADER)?.trim();
let kinds = actix_header_value(headers, PARTIAL_KIND_HEADER)?.trim();
if only.is_empty() || kinds.is_empty() {
return None;
}
let keys = only
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty());
let kinds = kinds
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty());
let entries = keys
.zip(kinds)
.filter_map(|(key, kind)| {
let kind = match kind {
"props" => PartialReloadKind::Props,
"resources" => PartialReloadKind::Resources,
_ => return None,
};
Some(PartialReloadEntry {
kind,
key: key.to_owned(),
})
})
.collect::<Vec<_>>();
if entries.is_empty() {
return None;
}
Some(PartialReloadRequest {
component: component.to_owned(),
entries,
})
}
fn actix_header_value<'a>(
headers: &'a actix_web::http::header::HeaderMap,
name: &str,
) -> Option<&'a str> {
headers.get(name)?.to_str().ok()
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test::TestRequest;
use tokio_stream::iter;
use super::*;
use crate::BrowserStreamEvent;
#[actix_web::test]
async fn hard_redirect_response_sets_protocol_header() {
let response = hard_redirect_response("/moved");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response.headers().get(PAGE_LOCATION_HEADER).unwrap(),
"/moved"
);
}
#[actix_web::test]
async fn ndjson_stream_response_serializes_item_done_and_error() {
let response = ndjson_stream_response(iter(vec![
Ok::<_, std::io::Error>(BrowserStreamEvent::Item {
data: serde_json::json!({ "id": 1, "name": "Ada" }),
}),
Ok(BrowserStreamEvent::Done),
Err(std::io::Error::other("boom")),
]));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"application/x-ndjson; charset=utf-8"
);
}
#[test]
fn actix_request_helpers_parse_page_protocol_headers() {
let request = TestRequest::default()
.insert_header((PAGE_VISIT_HEADER, "true"))
.insert_header((PAGE_VERSION_HEADER, "abc123"))
.to_http_request();
assert!(actix_wants_page_protocol(request.headers()));
assert_eq!(actix_page_version(request.headers()), Some("abc123"));
}
#[test]
fn actix_partial_reload_request_parses_component_and_entries() {
let request = TestRequest::default()
.insert_header((PAGE_VISIT_HEADER, "true"))
.insert_header((PARTIAL_COMPONENT_HEADER, "Dashboard"))
.insert_header((PARTIAL_ONLY_HEADER, "profile, stats"))
.insert_header((PARTIAL_KIND_HEADER, "props, resources"))
.to_http_request();
assert_eq!(
actix_partial_reload_request(request.headers()),
Some(PartialReloadRequest {
component: "Dashboard".into(),
entries: vec![
PartialReloadEntry {
kind: PartialReloadKind::Props,
key: "profile".into(),
},
PartialReloadEntry {
kind: PartialReloadKind::Resources,
key: "stats".into(),
},
],
})
);
}
}