vorma 0.86.0-pre.3

Vorma framework.
Documentation
use std::collections::BTreeMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use serde_json::Value;
use vorma_tasks::{CancelToken, Tasks, TasksOptions};

use super::*;
use crate::mux::RawRequest;
use crate::response::CLIENT_ACCEPTS_REDIRECT_HEADER;

fn exec_ctx() -> vorma_tasks::ExecCtx<&'static str> {
	Tasks::new(TasksOptions::default()).exec_ctx(CancelToken::new())
}

fn boxed_exec_ctx() -> vorma_tasks::ExecCtx<Box<dyn std::error::Error + Send + Sync>> {
	Tasks::new(TasksOptions::default()).exec_ctx(CancelToken::new())
}

crate::app!(mod macro_app for ());

#[derive(Clone, Debug, Deserialize, crate::TsGen)]
#[serde(rename_all = "camelCase")]
struct MacroStoryInput {
	draft_title: Option<String>,
}

#[derive(Clone, Debug, Serialize, crate::TsGen)]
#[serde(rename_all = "camelCase")]
struct MacroStoryOutput {
	story_id: String,
}

#[derive(Clone, Debug, Serialize, crate::TsGen)]
#[serde(rename_all = "camelCase")]
struct MacroAssetOutput {
	app_css: String,
}

pub const MACRO_STORY_VIEW: macro_app::View = macro_app::view! {
	client_file: "story.view.tsx";
	pattern: "/stories/:story_id";
	input: MacroStoryInput;
	output: MacroStoryOutput;

	handler: |ctx| {
		assert_eq!(ctx.params().story_id, "123");
		assert_eq!(ctx.input().draft_title, Option::None);
		Ok(MacroStoryOutput {
			story_id: ctx.params().story_id.clone(),
		})
	};
};

pub const MACRO_ASSET_VIEW: macro_app::View = macro_app::view! {
	client_file: "asset.view.tsx";
	pattern: "/assets";
	input: ();
	output: MacroAssetOutput;

	handler: |ctx| {
		Ok(MacroAssetOutput {
			app_css: ctx.public_url("app.css")?,
		})
	};
};

pub const MACRO_STORY_API: macro_app::Resource = macro_app::resource! {
	kind: crate::ResourceKind::Mutation;
	method: crate::HttpMethod::GET;
	pattern: "/stories/:story_id";
	input: MacroStoryInput;
	output: MacroStoryOutput;

	handler: |ctx| {
		assert_eq!(ctx.params().story_id, "456");
		assert_eq!(ctx.input().draft_title, Option::None);
		Ok(MacroStoryOutput {
			story_id: ctx.params().story_id.clone(),
		})
	};
};

#[test]
fn accepts_client_redirect_matches_bool_header_semantics() {
	let mut headers = http::HeaderMap::new();
	assert!(!accepts_client_redirect(&headers));

	headers.insert(
		CLIENT_ACCEPTS_REDIRECT_HEADER,
		http::HeaderValue::from_static("false"),
	);
	assert!(!accepts_client_redirect(&headers));

	headers.insert(
		CLIENT_ACCEPTS_REDIRECT_HEADER,
		http::HeaderValue::from_static("true"),
	);
	assert!(accepts_client_redirect(&headers));
}

#[test]
fn resource_kind_defaults_follow_method() {
	let get_route: Resource<(), &'static str> =
		Resource::without_handler(Method::GET, "/health", Option::None);
	let post_route: Resource<(), &'static str> =
		Resource::without_handler(Method::POST, "/sessions", Option::None);
	let explicit: Resource<(), &'static str> =
		Resource::without_handler(Method::POST, "/search", Some(ResourceKind::Query));

	assert_eq!(get_route.kind(), Option::None);
	assert_eq!(post_route.kind(), Option::None);
	assert_eq!(explicit.kind(), Some(ResourceKind::Query));
	assert_eq!(
		default_resource_kind_for_method(get_route.method()),
		ResourceKind::Query
	);
	assert_eq!(
		default_resource_kind_for_method(post_route.method()),
		ResourceKind::Mutation
	);
}

#[test]
fn contract_for_validates_view_patterns_before_contract_output() {
	let mut views: Views<(), &'static str> = Views::new();
	views.push(View::new(
		"",
		"bad.view.tsx",
		InputParser::<()>::default_input(),
		|_: ViewCtx<(), &'static str, ()>| async { Ok(()) },
	));
	let resources: Resources<(), &'static str> = Resources::new();

	let error = contract_for(&views, &resources).unwrap_err();

	assert!(error.starts_with("error validating app route contract:"));
	assert!(error.contains("pattern must not be empty"));
}

#[test]
fn contract_for_validates_resource_patterns_before_contract_output() {
	let views: Views<(), &'static str> = Views::new();
	let mut resources: Resources<(), &'static str> = Resources::new();
	resources.push(Resource::new(
		Method::GET,
		"",
		Option::None,
		InputParser::<()>::default_input(),
		|_: ResourceCtx<(), &'static str, ()>| async { Ok(()) },
	));

	let error = contract_for(&views, &resources).unwrap_err();

	assert!(error.starts_with("error validating app route contract:"));
	assert!(error.contains("resource pattern must not be empty"));
}

#[test]
fn contract_for_validates_resource_collisions_before_contract_output() {
	let views: Views<(), &'static str> = Views::new();
	let mut resources: Resources<(), &'static str> = Resources::new();
	resources.push(Resource::new(
		Method::GET,
		"/stories/:story_id",
		Option::None,
		InputParser::<()>::default_input(),
		|_: ResourceCtx<(), &'static str, ()>| async { Ok(()) },
	));
	resources.push(Resource::new(
		Method::GET,
		"/stories/:id",
		Option::None,
		InputParser::<()>::default_input(),
		|_: ResourceCtx<(), &'static str, ()>| async { Ok(()) },
	));

	let error = contract_for(&views, &resources).unwrap_err();

	assert!(error.starts_with("error validating app route contract:"));
	assert!(error.contains("route shape collision"));
}

#[tokio::test]
async fn runtime_routes_register_views_and_resources() {
	let mut views: Views<(), &'static str> = Views::new();
	views.push(View::new(
		"/users/:id",
		"users.view.tsx",
		InputParser::<()>::default_input(),
		|ctx: ViewCtx<(), &'static str, ()>| async move {
			assert_eq!(ctx.request().path(), "/users/123");
			ctx.head().title("User");
			Ok(ctx.param("id").to_owned())
		},
	));

	let mut resources: Resources<(), &'static str> = Resources::new();
	resources.push(Resource::new(
		Method::GET,
		"/users/:id",
		Option::None,
		InputParser::<()>::default_input(),
		|ctx: ResourceCtx<(), &'static str, ()>| async move {
			ctx.response().set_status(http::StatusCode::CREATED);
			Ok(ctx.param("id").to_owned())
		},
	));

	let middlewares = Middlewares::new();
	let routes = runtime_routes_for(&views, &resources, &middlewares, "/api/").unwrap();
	let view_matches = routes
		.views
		.find_nested_matches("/users/123")
		.unwrap()
		.unwrap();
	let view_results = routes
		.views
		.execute_view_stack(
			Arc::new(()),
			exec_ctx(),
			RawRequest::get("/users/123"),
			view_matches,
			Arc::new(std::collections::BTreeMap::new()),
		)
		.await
		.unwrap();
	assert_eq!(
		view_results.view_results()[0]
			.data()
			.and_then(Value::as_str),
		Some("123")
	);

	let api_result = routes
		.resources
		.execute_route(
			RawRequest::get("/api/users/456"),
			Arc::new(()),
			exec_ctx(),
			Arc::new(std::collections::BTreeMap::new()),
		)
		.await
		.unwrap()
		.unwrap();
	assert_eq!(api_result.data().and_then(Value::as_str), Some("456"));
	assert_eq!(
		api_result.response_effects().status().0,
		Some(http::StatusCode::CREATED)
	);
}

#[tokio::test]
async fn macros_define_const_routes_with_inferred_typed_params() {
	let views = macro_app::views![MACRO_STORY_VIEW];
	let resources = macro_app::resources![MACRO_STORY_API];

	let middlewares = Middlewares::new();
	let routes = runtime_routes_for(&views, &resources, &middlewares, "/api/").unwrap();
	let view_matches = routes
		.views
		.find_nested_matches("/stories/123")
		.unwrap()
		.unwrap();
	let view_results = routes
		.views
		.execute_view_stack(
			Arc::new(()),
			boxed_exec_ctx(),
			RawRequest::get("/stories/123"),
			view_matches,
			Arc::new(std::collections::BTreeMap::new()),
		)
		.await
		.unwrap();
	assert_eq!(
		view_results.view_results()[0].data().unwrap()["storyId"],
		"123"
	);

	let api_result = routes
		.resources
		.execute_route(
			RawRequest::get("/api/stories/456"),
			Arc::new(()),
			boxed_exec_ctx(),
			Arc::new(std::collections::BTreeMap::new()),
		)
		.await
		.unwrap()
		.unwrap();
	assert_eq!(api_result.data().unwrap()["storyId"], "456");
}

#[tokio::test]
async fn macros_support_public_url_in_default_error_context() {
	let views = macro_app::views![MACRO_ASSET_VIEW];
	let resources = macro_app::resources![];
	let middlewares = macro_app::middlewares![];
	let routes = runtime_routes_for(&views, &resources, &middlewares, "/api/").unwrap();
	let view_matches = routes
		.views
		.find_nested_matches("/assets")
		.unwrap()
		.unwrap();
	let view_results = routes
		.views
		.execute_view_stack(
			Arc::new(()),
			boxed_exec_ctx(),
			RawRequest::get("/assets"),
			view_matches,
			Arc::new(BTreeMap::from([(
				"app.css".to_owned(),
				"/static/app.css".to_owned(),
			)])),
		)
		.await
		.unwrap();

	assert_eq!(
		view_results.view_results()[0].data().unwrap()["appCss"],
		"/static/app.css"
	);
}

#[tokio::test]
async fn public_middlewares_wire_once_and_filter_from_request_context() {
	let views = macro_app::views![MACRO_STORY_VIEW];
	let resources = macro_app::resources![MACRO_STORY_API];
	let middlewares = macro_app::middlewares![macro_app::Middleware::new(|ctx| async move {
		if ctx.request().method() != Method::GET {
			return Ok(());
		}
		if ctx.matched_pattern() != "/stories/:story_id" {
			return Ok(());
		}
		let story_id = ctx.param("story_id");
		let params = ctx.params();
		assert_eq!(params.len(), 1);
		assert!(!params.is_empty());
		assert_eq!(params.get("story_id"), Some(story_id));
		assert_eq!(
			params.iter().collect::<Vec<_>>(),
			vec![("story_id", story_id)]
		);
		let header_value = http::HeaderValue::from_str(story_id).unwrap();
		ctx.response()
			.set_header(http::HeaderName::from_static("x-vorma-story"), header_value);
		Ok(())
	})];
	let routes = runtime_routes_for(&views, &resources, &middlewares, "/api/").unwrap();

	let view_matches = routes
		.views
		.find_nested_matches("/stories/123")
		.unwrap()
		.unwrap();
	let view_results = routes
		.views
		.execute_view_stack(
			Arc::new(()),
			boxed_exec_ctx(),
			RawRequest::get("/stories/123"),
			view_matches,
			Arc::new(BTreeMap::new()),
		)
		.await
		.unwrap();
	let view_header = view_results
		.middleware_effects()
		.header(&http::HeaderName::from_static("x-vorma-story"))
		.unwrap();
	assert_eq!(view_header.to_str().unwrap(), "123");

	let api_result = routes
		.resources
		.execute_route(
			RawRequest::get("/api/stories/456"),
			Arc::new(()),
			boxed_exec_ctx(),
			Arc::new(BTreeMap::new()),
		)
		.await
		.unwrap()
		.unwrap();
	let api_header = api_result
		.response_effects()
		.header(&http::HeaderName::from_static("x-vorma-story"))
		.unwrap();
	assert_eq!(api_header.to_str().unwrap(), "456");
}