feat(api): streamline generated app authoring
Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path. Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check. req: canonical/001 req: canonical/003 req: canonical/004 req: dx/002 req: derive_app/001 req: component/003 req: form/004 req: axum_integration/003
This commit is contained in:
+231
-28
@@ -1,18 +1,50 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::IntoResponse;
|
||||
use scraper::{Html, Selector};
|
||||
use slhx_axum::{
|
||||
interactions, runtime_js, DispatchRejection, EffectResponse, InteractionForm,
|
||||
interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
|
||||
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
|
||||
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
|
||||
SLHX_TITLE_HEADER,
|
||||
};
|
||||
use slhx_core::{push, BuildFingerprint, Handle, SafeHtml, Slot};
|
||||
use slhx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct ProjectId(u32);
|
||||
|
||||
impl std::str::FromStr for ProjectId {
|
||||
type Err = std::num::ParseIntError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
value.parse().map(ProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenProject {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
impl slhx_core::FromForm for OpenProject {
|
||||
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, slhx_core::FormError> {
|
||||
let Some(value) = fields
|
||||
.iter()
|
||||
.find_map(|(name, value)| (name == "project_id").then_some(value.as_str()))
|
||||
else {
|
||||
return Err(slhx_core::FormError::new("missing form field `project_id`"));
|
||||
};
|
||||
Ok(Self {
|
||||
project_id: value
|
||||
.parse()
|
||||
.map_err(|_| slhx_core::FormError::new("invalid form field `project_id`"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_mode_detects_partial_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -25,27 +57,43 @@ fn page_mode_detects_partial_header() {
|
||||
#[test]
|
||||
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
||||
// req: test/005
|
||||
let full = PageRequest { mode: PageMode::Full }.page(
|
||||
"<main data-page=\"docs\">Docs</main>",
|
||||
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
|
||||
);
|
||||
let full = PageRequest {
|
||||
mode: PageMode::Full,
|
||||
}
|
||||
.page("<main data-page=\"docs\">Docs</main>", |content| {
|
||||
format!("<html><body data-shell=\"docs\">{content}</body></html>")
|
||||
});
|
||||
assert_eq!(full.mode, PageMode::Full);
|
||||
let full_document = Html::parse_document(&full.html);
|
||||
assert_eq!(
|
||||
full_document
|
||||
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
|
||||
.select(&selector(
|
||||
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let partial = PageRequest { mode: PageMode::Partial }.page(
|
||||
"<main data-page=\"docs\">Docs</main>",
|
||||
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
|
||||
);
|
||||
let partial = PageRequest {
|
||||
mode: PageMode::Partial,
|
||||
}
|
||||
.page("<main data-page=\"docs\">Docs</main>", |content| {
|
||||
format!("<html><body data-shell=\"docs\">{content}</body></html>")
|
||||
});
|
||||
assert_eq!(partial.mode, PageMode::Partial);
|
||||
let partial_fragment = Html::parse_fragment(&partial.html);
|
||||
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
|
||||
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
|
||||
assert_eq!(
|
||||
partial_fragment
|
||||
.select(&selector("main[data-page=\"docs\"]"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
partial_fragment
|
||||
.select(&selector("body[data-shell=\"docs\"]"))
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -66,7 +114,9 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
|
||||
let full_document = Html::parse_document(&full.html);
|
||||
assert_eq!(
|
||||
full_document
|
||||
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
|
||||
.select(&selector(
|
||||
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
@@ -84,8 +134,18 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
|
||||
);
|
||||
assert_eq!(partial.mode, PageMode::Partial);
|
||||
let partial_fragment = Html::parse_fragment(&partial.html);
|
||||
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
|
||||
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
|
||||
assert_eq!(
|
||||
partial_fragment
|
||||
.select(&selector("main[data-page=\"docs\"]"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
partial_fragment
|
||||
.select(&selector("body[data-shell=\"docs\"]"))
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -94,7 +154,10 @@ fn partial_page_response_sets_partial_and_title_headers() {
|
||||
.title("Docs")
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
|
||||
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
|
||||
}
|
||||
@@ -109,8 +172,8 @@ fn effect_response_is_wire_batch_with_fingerprint_header() {
|
||||
|
||||
#[test]
|
||||
fn interaction_form_parses_handle_and_fields() {
|
||||
let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc")
|
||||
.unwrap();
|
||||
let form =
|
||||
InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc").unwrap();
|
||||
|
||||
assert_eq!(form.handle_id, 42);
|
||||
assert_eq!(form.value("title"), Some("Hello World"));
|
||||
@@ -120,8 +183,8 @@ fn interaction_form_parses_handle_and_fields() {
|
||||
#[test]
|
||||
fn interaction_form_parses_typed_values() {
|
||||
// req: form/004 req: dx/003
|
||||
let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope")
|
||||
.expect("form should parse");
|
||||
let form =
|
||||
InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
|
||||
|
||||
assert_eq!(form.parse::<u32>("count"), Some(7));
|
||||
assert_eq!(form.parse::<u32>("bad"), None);
|
||||
@@ -143,8 +206,7 @@ fn interaction_form_requires_numeric_handle() {
|
||||
#[test]
|
||||
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
|
||||
// req: auth/004
|
||||
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello")
|
||||
.unwrap();
|
||||
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
|
||||
|
||||
assert_eq!(form.handle_id, 42);
|
||||
assert_eq!(form.value("csrf_token"), Some("abc123"));
|
||||
@@ -159,15 +221,151 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
|
||||
Vec::new(),
|
||||
));
|
||||
let response = request
|
||||
.dispatch(interactions(BuildFingerprint(4)).on(Handle::<()>::new(7), |_| {
|
||||
Slot::<String>::new(3).text("ok")
|
||||
}))
|
||||
.dispatch(
|
||||
interactions(BuildFingerprint(4))
|
||||
.on(Handle::<()>::new(7), |_| Slot::<String>::new(3).text("ok")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
|
||||
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_request_dispatches_typed_form_inputs() {
|
||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||
Handle::<()>::new(8),
|
||||
vec![("project_id".to_owned(), "42".to_owned())],
|
||||
));
|
||||
let response = request
|
||||
.dispatch(
|
||||
interactions(BuildFingerprint(4))
|
||||
.on_form(Handle::<()>::new(8), |input: OpenProject| {
|
||||
Slot::<String>::new(3).text(input.project_id.0)
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
|
||||
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_request_rejects_invalid_typed_form_inputs() {
|
||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
|
||||
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||
Handle::<()>::new(8),
|
||||
vec![("project_id".to_owned(), "nope".to_owned())],
|
||||
));
|
||||
let rejection = request
|
||||
.dispatch(
|
||||
interactions(BuildFingerprint(4))
|
||||
.on_form(Handle::<()>::new(8), |input: OpenProject| {
|
||||
Slot::<String>::new(3).text(input.project_id.0)
|
||||
}),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
rejection,
|
||||
DispatchRejection::InvalidForm { handle_id: 8, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_request_dispatches_typed_state_handlers() {
|
||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
|
||||
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
|
||||
}
|
||||
|
||||
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||
Handle::<()>::new(9),
|
||||
vec![("project_id".to_owned(), "21".to_owned())],
|
||||
));
|
||||
let response = request
|
||||
.dispatch(
|
||||
interactions(BuildFingerprint(4))
|
||||
.with_state(2_u32)
|
||||
.on(Handle::<()>::new(9), open_project),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HandlerBoom;
|
||||
|
||||
impl std::fmt::Display for HandlerBoom {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("database unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HandlerBoom {}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_request_dispatches_async_typed_state_extractors() {
|
||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||
async fn open_project(
|
||||
State(multiplier): State<u32>,
|
||||
Form(input): Form<OpenProject>,
|
||||
) -> impl IntoEffect {
|
||||
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
|
||||
}
|
||||
|
||||
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||
Handle::<()>::new(10),
|
||||
vec![("project_id".to_owned(), "21".to_owned())],
|
||||
));
|
||||
let response = request
|
||||
.dispatch_async(
|
||||
interactions(BuildFingerprint(4))
|
||||
.with_state(2_u32)
|
||||
.on_async(Handle::<()>::new(10), open_project)
|
||||
.into_registry(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_request_reports_async_result_handler_errors() {
|
||||
// req: axum_integration/003 req: form/004 req: failure/003
|
||||
async fn open_project(
|
||||
State(_multiplier): State<u32>,
|
||||
Form(_input): Form<OpenProject>,
|
||||
) -> Result<impl IntoEffect, HandlerBoom> {
|
||||
Err::<(), _>(HandlerBoom)
|
||||
}
|
||||
|
||||
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||
Handle::<()>::new(11),
|
||||
vec![("project_id".to_owned(), "21".to_owned())],
|
||||
));
|
||||
let error = request
|
||||
.dispatch_async(
|
||||
interactions(BuildFingerprint(4))
|
||||
.with_state(2_u32)
|
||||
.on_async_result(Handle::<()>::new(11), open_project)
|
||||
.into_registry(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
DispatchRejection::HandlerError {
|
||||
handle_id: 11,
|
||||
message: "database unavailable".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_dispatch_by_checked_handle() {
|
||||
// req: ceremony/004 req: public_api/001
|
||||
@@ -193,7 +391,9 @@ fn interactions_reject_unknown_handle_ids() {
|
||||
let request = InteractionRequest::from(InteractionForm::new(9, []));
|
||||
|
||||
assert_eq!(
|
||||
request.dispatch(interactions(BuildFingerprint(123))).unwrap_err(),
|
||||
request
|
||||
.dispatch(interactions(BuildFingerprint(123)))
|
||||
.unwrap_err(),
|
||||
DispatchRejection::UnknownHandle(9)
|
||||
);
|
||||
}
|
||||
@@ -202,6 +402,9 @@ fn interactions_reject_unknown_handle_ids() {
|
||||
fn runtime_js_response_serves_embedded_runtime() {
|
||||
let response = runtime_js().into_response();
|
||||
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_RUNTIME_CONTENT_TYPE);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
SLHX_RUNTIME_CONTENT_TYPE
|
||||
);
|
||||
assert!(response.headers().contains_key(header::CACHE_CONTROL));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user