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:
+647
-23
@@ -1,13 +1,17 @@
|
||||
use axum::async_trait;
|
||||
use axum::body::{to_bytes, Body};
|
||||
pub use axum::extract::State;
|
||||
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect, SafeHtml};
|
||||
use slhx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
|
||||
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
|
||||
@@ -54,7 +58,11 @@ impl PageRequest {
|
||||
matches!(self.mode, PageMode::Partial)
|
||||
}
|
||||
|
||||
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse {
|
||||
pub fn page(
|
||||
self,
|
||||
partial_html: impl Into<String>,
|
||||
shell: impl FnOnce(String) -> String,
|
||||
) -> PageResponse {
|
||||
let partial_html = partial_html.into();
|
||||
match self.mode {
|
||||
PageMode::Full => PageResponse::full(shell(partial_html)),
|
||||
@@ -62,14 +70,14 @@ impl PageRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_html(
|
||||
self,
|
||||
partial_html: SafeHtml,
|
||||
shell: impl FnOnce(SafeHtml) -> SafeHtml,
|
||||
) -> PageResponse {
|
||||
pub fn page_html<P, S>(self, partial_html: P, shell: impl FnOnce(P) -> S) -> PageResponse
|
||||
where
|
||||
P: Into<SafeHtml>,
|
||||
S: Into<SafeHtml>,
|
||||
{
|
||||
match self.mode {
|
||||
PageMode::Full => PageResponse::full(shell(partial_html).into_string()),
|
||||
PageMode::Partial => PageResponse::partial(partial_html.into_string()),
|
||||
PageMode::Full => PageResponse::full(shell(partial_html).into().into_string()),
|
||||
PageMode::Partial => PageResponse::partial(partial_html.into().into_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,9 +153,73 @@ pub trait DispatchRegistry {
|
||||
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
|
||||
}
|
||||
|
||||
pub trait FromInteractionForm: Sized {
|
||||
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Form<T>(pub T);
|
||||
|
||||
impl<T> Form<T> {
|
||||
pub fn into_inner(self) -> T {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Form<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromInteractionForm for T
|
||||
where
|
||||
T: FromForm,
|
||||
{
|
||||
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
|
||||
T::from_form_fields(form.fields())
|
||||
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromInteractionForm for Form<T>
|
||||
where
|
||||
T: FromForm,
|
||||
{
|
||||
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
|
||||
T::from_form_fields(form.fields())
|
||||
.map(Self)
|
||||
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait FromHandlerState<S>: Sized {
|
||||
fn from_handler_state(state: S) -> Self;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FormDecodeError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
type SyncHandler =
|
||||
Box<dyn Fn(InteractionForm) -> Result<EffectBatch, DispatchRejection> + Send + Sync>;
|
||||
type HandlerFuture = Pin<Box<dyn Future<Output = Result<EffectBatch, DispatchRejection>> + Send>>;
|
||||
type AsyncHandler = Box<dyn Fn(InteractionForm) -> HandlerFuture + Send + Sync>;
|
||||
|
||||
pub struct HandlerRegistry {
|
||||
fingerprint: BuildFingerprint,
|
||||
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
|
||||
handlers: BTreeMap<u32, SyncHandler>,
|
||||
async_handlers: BTreeMap<u32, AsyncHandler>,
|
||||
}
|
||||
|
||||
pub type Registry = HandlerRegistry;
|
||||
|
||||
pub struct StateHandlerRegistry<S> {
|
||||
registry: HandlerRegistry,
|
||||
state: S,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -160,6 +232,8 @@ pub enum InteractionFormRejection {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DispatchRejection {
|
||||
UnknownHandle(u32),
|
||||
InvalidForm { handle_id: u32, message: String },
|
||||
HandlerError { handle_id: u32, message: String },
|
||||
}
|
||||
|
||||
impl EffectResponse {
|
||||
@@ -170,6 +244,30 @@ impl EffectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromHandlerState<S> for S {
|
||||
fn from_handler_state(state: S) -> Self {
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromHandlerState<S> for State<S> {
|
||||
fn from_handler_state(state: S) -> Self {
|
||||
State(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl FormDecodeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractionForm {
|
||||
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||
Self {
|
||||
@@ -179,7 +277,10 @@ impl InteractionForm {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_handle<I>(handle: Handle<I>, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||
pub fn for_handle<I>(
|
||||
handle: Handle<I>,
|
||||
fields: impl IntoIterator<Item = (String, String)>,
|
||||
) -> Self {
|
||||
Self::new(handle.id().id, fields)
|
||||
}
|
||||
|
||||
@@ -188,7 +289,9 @@ impl InteractionForm {
|
||||
}
|
||||
|
||||
// req: multipart/001, req: multipart/002
|
||||
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> {
|
||||
pub async fn parse_multipart(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Self, InteractionFormRejection> {
|
||||
let mut fields = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -224,7 +327,10 @@ impl InteractionForm {
|
||||
Self::from_parts(fields, files)
|
||||
}
|
||||
|
||||
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> {
|
||||
fn from_parts(
|
||||
fields: Vec<(String, String)>,
|
||||
files: Vec<InteractionFile>,
|
||||
) -> Result<Self, InteractionFormRejection> {
|
||||
let Some(handle) = fields
|
||||
.iter()
|
||||
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||
@@ -234,7 +340,11 @@ impl InteractionForm {
|
||||
let handle_id = handle
|
||||
.parse::<u32>()
|
||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||
Ok(Self { handle_id, fields, files })
|
||||
Ok(Self {
|
||||
handle_id,
|
||||
fields,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&str> {
|
||||
@@ -267,6 +377,20 @@ impl InteractionForm {
|
||||
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
|
||||
self.files.iter().find(|file| file.name == name)
|
||||
}
|
||||
|
||||
pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> {
|
||||
self.value(name)
|
||||
.ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`")))
|
||||
}
|
||||
|
||||
pub fn parse_required<T>(&self, name: &str) -> Result<T, FormDecodeError>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
{
|
||||
self.required(name)?
|
||||
.parse()
|
||||
.map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`")))
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
|
||||
@@ -285,6 +409,13 @@ impl InteractionRequest {
|
||||
registry.dispatch_form(self.form)
|
||||
}
|
||||
|
||||
pub async fn dispatch_async(
|
||||
self,
|
||||
registry: HandlerRegistry,
|
||||
) -> Result<EffectResponse, DispatchRejection> {
|
||||
registry.dispatch_async(self.form).await
|
||||
}
|
||||
|
||||
pub fn form(&self) -> &InteractionForm {
|
||||
&self.form
|
||||
}
|
||||
@@ -301,6 +432,7 @@ impl HandlerRegistry {
|
||||
Self {
|
||||
fingerprint,
|
||||
handlers: BTreeMap::new(),
|
||||
async_handlers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +447,240 @@ impl HandlerRegistry {
|
||||
let fingerprint = self.fingerprint;
|
||||
self.handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| handler(form).into_batch(fingerprint)),
|
||||
Box::new(move |form| Ok(handler(form).into_batch(fingerprint))),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_typed<T, E>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
handler: impl Fn(T) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
T: FromInteractionForm,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let handle_id = form.handle_id;
|
||||
let input = T::from_interaction_form(&form).map_err(|error| {
|
||||
DispatchRejection::InvalidForm {
|
||||
handle_id,
|
||||
message: error.message,
|
||||
}
|
||||
})?;
|
||||
Ok(handler(input).into_batch(fingerprint))
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_state<S, C, E>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
state: S,
|
||||
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |_| {
|
||||
Ok(handler(C::from_handler_state(state.clone())).into_batch(fingerprint))
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_state_typed<S, C, T, E>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let handle_id = form.handle_id;
|
||||
let input = T::from_interaction_form(&form).map_err(|error| {
|
||||
DispatchRejection::InvalidForm {
|
||||
handle_id,
|
||||
message: error.message,
|
||||
}
|
||||
})?;
|
||||
Ok(handler(C::from_handler_state(state.clone()), input).into_batch(fingerprint))
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_async<E, F>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.async_handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let future = handler(form);
|
||||
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_typed_async<T, E, F>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
handler: impl Fn(T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.async_handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let handle_id = form.handle_id;
|
||||
let input = match T::from_interaction_form(&form) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
return Box::pin(async move {
|
||||
Err(DispatchRejection::InvalidForm {
|
||||
handle_id,
|
||||
message: error.message,
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let future = handler(input);
|
||||
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_state_async<S, C, E, F>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
state: S,
|
||||
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.async_handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |_| {
|
||||
let future = handler(C::from_handler_state(state.clone()));
|
||||
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_state_typed_async<S, C, T, E, F>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.async_handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let handle_id = form.handle_id;
|
||||
let input = match T::from_interaction_form(&form) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
return Box::pin(async move {
|
||||
Err(DispatchRejection::InvalidForm {
|
||||
handle_id,
|
||||
message: error.message,
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let future = handler(C::from_handler_state(state.clone()), input);
|
||||
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||
O: IntoEffect,
|
||||
E: fmt::Display,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.async_handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| {
|
||||
let handle_id = form.handle_id;
|
||||
let input = match T::from_interaction_form(&form) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
return Box::pin(async move {
|
||||
Err(DispatchRejection::InvalidForm {
|
||||
handle_id,
|
||||
message: error.message,
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let future = handler(C::from_handler_state(state.clone()), input);
|
||||
Box::pin(async move {
|
||||
future
|
||||
.await
|
||||
.map(|effects| effects.into_batch(fingerprint))
|
||||
.map_err(|error| DispatchRejection::HandlerError {
|
||||
handle_id,
|
||||
message: error.to_string(),
|
||||
})
|
||||
})
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
@@ -331,6 +696,16 @@ impl HandlerRegistry {
|
||||
self.register(handle.id().id, handler)
|
||||
}
|
||||
|
||||
pub fn with_state<S>(self, state: S) -> StateHandlerRegistry<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
StateHandlerRegistry {
|
||||
registry: self,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on<I, E>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
@@ -342,18 +717,243 @@ impl HandlerRegistry {
|
||||
self.register_handle(handle, handler)
|
||||
}
|
||||
|
||||
pub fn on_form<I, T, E>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(T) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
T: FromInteractionForm,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_typed(handle.id().id, handler)
|
||||
}
|
||||
|
||||
pub fn on_state<I, S, C, E>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
state: S,
|
||||
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_state(handle.id().id, state, handler)
|
||||
}
|
||||
|
||||
pub fn on_state_form<I, S, C, T, E>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_state_typed(handle.id().id, state, handler)
|
||||
}
|
||||
|
||||
pub fn on_async<I, E, F>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_async(handle.id().id, handler)
|
||||
}
|
||||
|
||||
pub fn on_form_async<I, T, E, F>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_typed_async(handle.id().id, handler)
|
||||
}
|
||||
|
||||
pub fn on_state_async<I, S, C, E, F>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
state: S,
|
||||
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_state_async(handle.id().id, state, handler)
|
||||
}
|
||||
|
||||
pub fn on_state_form_async<I, S, C, T, E, F>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.register_state_typed_async(handle.id().id, state, handler)
|
||||
}
|
||||
|
||||
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
|
||||
self,
|
||||
handle: Handle<I>,
|
||||
state: S,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||
O: IntoEffect,
|
||||
E: fmt::Display,
|
||||
{
|
||||
self.register_state_typed_async_result(handle.id().id, state, handler)
|
||||
}
|
||||
|
||||
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||
let handle_id = form.handle_id;
|
||||
let Some(handler) = self.handlers.get(&handle_id) else {
|
||||
return Err(DispatchRejection::UnknownHandle(handle_id));
|
||||
};
|
||||
Ok(EffectResponse {
|
||||
batch: handler(form),
|
||||
batch: handler(form)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn dispatch_async(
|
||||
&self,
|
||||
form: InteractionForm,
|
||||
) -> Result<EffectResponse, DispatchRejection> {
|
||||
let handle_id = form.handle_id;
|
||||
if let Some(handler) = self.async_handlers.get(&handle_id) {
|
||||
return Ok(EffectResponse {
|
||||
batch: handler(form).await?,
|
||||
});
|
||||
}
|
||||
self.dispatch(form)
|
||||
}
|
||||
|
||||
pub fn contains(&self, handle_id: u32) -> bool {
|
||||
self.handlers.contains_key(&handle_id)
|
||||
self.handlers.contains_key(&handle_id) || self.async_handlers.contains_key(&handle_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> StateHandlerRegistry<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
pub fn on_state<I, C, E>(
|
||||
mut self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
C: FromHandlerState<S>,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.registry = self.registry.on_state(handle, self.state.clone(), handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on<I, C, T, E>(
|
||||
mut self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.registry = self
|
||||
.registry
|
||||
.on_state_form(handle, self.state.clone(), handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_state_async<I, C, E, F>(
|
||||
mut self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
C: FromHandlerState<S>,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.registry = self
|
||||
.registry
|
||||
.on_state_async(handle, self.state.clone(), handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_async<I, C, T, E, F>(
|
||||
mut self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = E> + Send + 'static,
|
||||
E: IntoEffect,
|
||||
{
|
||||
self.registry = self
|
||||
.registry
|
||||
.on_state_form_async(handle, self.state.clone(), handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_async_result<I, C, T, E, O, F>(
|
||||
mut self,
|
||||
handle: Handle<I>,
|
||||
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
C: FromHandlerState<S>,
|
||||
T: FromInteractionForm,
|
||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||
O: IntoEffect,
|
||||
E: fmt::Display,
|
||||
{
|
||||
self.registry =
|
||||
self.registry
|
||||
.on_state_form_async_result(handle, self.state.clone(), handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_registry(self) -> HandlerRegistry {
|
||||
self.registry
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> DispatchRegistry for StateHandlerRegistry<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||
self.registry.dispatch_form(form)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +1024,10 @@ fn is_multipart(headers: &HeaderMap) -> bool {
|
||||
|
||||
impl PageMode {
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
||||
match headers
|
||||
.get(SLHX_PARTIAL_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some("1" | "true") => Self::Partial,
|
||||
_ => Self::Full,
|
||||
}
|
||||
@@ -452,7 +1055,10 @@ impl IntoResponse for PageResponse {
|
||||
.headers_mut()
|
||||
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||
}
|
||||
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) {
|
||||
if let Some(title) = self
|
||||
.title
|
||||
.and_then(|title| HeaderValue::from_str(&title).ok())
|
||||
{
|
||||
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
|
||||
}
|
||||
response
|
||||
@@ -506,6 +1112,16 @@ impl IntoResponse for DispatchRejection {
|
||||
format!("unknown slhx handle id {handle_id}"),
|
||||
)
|
||||
.into_response(),
|
||||
Self::InvalidForm { handle_id, message } => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid slhx form for handle id {handle_id}: {message}"),
|
||||
)
|
||||
.into_response(),
|
||||
Self::HandlerError { handle_id, message } => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("slhx handler for handle id {handle_id} failed: {message}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,7 +1151,7 @@ where
|
||||
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
|
||||
E: Into<axum::BoxError>,
|
||||
{
|
||||
Sse::new(batches.map(|batch| batch.map(sse_event)))
|
||||
Sse::new(batches.map(|batch| batch.map(sse_event))).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
pub fn sse_event(batch: EffectBatch) -> Event {
|
||||
@@ -630,8 +1246,16 @@ fn hex(byte: u8) -> Option<u8> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT};
|
||||
use axum::{body::{to_bytes, Body}, extract::FromRequest, http::{header, Request}, response::IntoResponse};
|
||||
use super::{
|
||||
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
|
||||
SLHX_SSE_EVENT,
|
||||
};
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
extract::FromRequest,
|
||||
http::{header, Request},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures_util::stream;
|
||||
use scraper::{Html, Selector};
|
||||
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
||||
|
||||
+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