Localize Axum interaction forms
This commit is contained in:
@@ -40,16 +40,10 @@ Blocked by: HMX-M02
|
|||||||
|
|
||||||
## HMX-M04 — Localize Axum interaction forms
|
## HMX-M04 — Localize Axum interaction forms
|
||||||
|
|
||||||
|
Outcome: the interaction-form boundary is privately owned without public extraction, decoding, limit, or rejection drift.
|
||||||
Outcome: media-type enforcement, body limits, URL-encoded/multipart extraction, typed decoding, and rejections have one private Axum owner.
|
|
||||||
Delta: architecture/004 assurance/002
|
Delta: architecture/004 assurance/002
|
||||||
Path: HTTP request -> `InteractionRequest` extraction -> typed `Form<T>` or custom multipart model -> registered handler/rejection.
|
Checks: Axum interaction boundary suite; derive Form<T> spelling check; custom multipart typed extraction; Redgate; diff check; fresh review.
|
||||||
Build: move the complete form boundary with its tests; add compile coverage for documented `Form<T>` spellings and custom `FromInteractionForm` extraction.
|
State: Done
|
||||||
Risk: extraction order, limits, rejection status/body, or public adapter signatures change.
|
|
||||||
Checks: `cargo test -p hemx-axum`; `cargo test -p hemx-derive`; focused URL-encoded, multipart, limit, rejection, and compile-pass/fail assertions; `redgate check`.
|
|
||||||
Non-goals: framework-owned CSRF/auth policy, a general extractor abstraction, or changing native form semantics.
|
|
||||||
Residual risk: none beyond the existing application-owned security policy boundary after focused route proof.
|
|
||||||
State: Ready
|
|
||||||
Blocked by: none
|
Blocked by: none
|
||||||
|
|
||||||
## Closure
|
## Closure
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InteractionFile {
|
||||||
|
pub name: String,
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InteractionForm {
|
||||||
|
pub handle_id: u32,
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
files: Vec<InteractionFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated hemx mutation request.
|
||||||
|
///
|
||||||
|
/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are
|
||||||
|
/// accepted. Body size is intentionally host policy: apply Axum's
|
||||||
|
/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit)
|
||||||
|
/// to the mutation route; limit rejections become HTTP 413 before dispatch.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InteractionRequest {
|
||||||
|
pub(super) form: InteractionForm,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
handle_id,
|
||||||
|
fields: fields.into_iter().collect(),
|
||||||
|
files: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_handle<I>(
|
||||||
|
handle: Handle<I>,
|
||||||
|
fields: impl IntoIterator<Item = (String, String)>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(handle.id().id, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
||||||
|
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn parse_multipart(
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Self, InteractionFormRejection> {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?
|
||||||
|
{
|
||||||
|
let Some(name) = field.name().map(str::to_owned) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let file_name = field.file_name().map(str::to_owned);
|
||||||
|
let content_type = field.content_type().map(str::to_owned);
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
|
||||||
|
if file_name.is_some() {
|
||||||
|
files.push(InteractionFile {
|
||||||
|
name,
|
||||||
|
file_name,
|
||||||
|
content_type,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let value = String::from_utf8(bytes.to_vec())
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
fields.push((name, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::from_parts(fields, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
files: Vec<InteractionFile>,
|
||||||
|
) -> Result<Self, InteractionFormRejection> {
|
||||||
|
let Some(handle) = fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(name, value)| (name == HEMX_HANDLE_FIELD).then_some(value))
|
||||||
|
else {
|
||||||
|
return Err(InteractionFormRejection::MissingHandle);
|
||||||
|
};
|
||||||
|
let handle_id = handle
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||||
|
Ok(Self {
|
||||||
|
handle_id,
|
||||||
|
fields,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(field, value)| (field == name).then_some(value.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse<T>(&self, name: &str) -> Option<T>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
{
|
||||||
|
self.value(name).and_then(|value| value.parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(move |(field, value)| (field == name).then_some(value.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields(&self) -> &[(String, String)] {
|
||||||
|
&self.fields
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn files(&self) -> &[InteractionFile] {
|
||||||
|
&self.files
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for InteractionFormRejection {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
Self::UnsupportedMediaType => (
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
|
||||||
|
),
|
||||||
|
Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"),
|
||||||
|
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"),
|
||||||
|
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"),
|
||||||
|
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"),
|
||||||
|
};
|
||||||
|
(status, message).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequest<S> for InteractionRequest
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = InteractionFormRejection;
|
||||||
|
|
||||||
|
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
InteractionForm::from_request(request, state)
|
||||||
|
.await
|
||||||
|
.map(|form| Self { form })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequest<S> for InteractionForm
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = InteractionFormRejection;
|
||||||
|
|
||||||
|
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
match interaction_media_type(request.headers())? {
|
||||||
|
InteractionMediaType::Multipart => {
|
||||||
|
let multipart = Multipart::from_request(request, state)
|
||||||
|
.await
|
||||||
|
.map_err(extractor_rejection)?;
|
||||||
|
Self::parse_multipart(multipart).await
|
||||||
|
}
|
||||||
|
InteractionMediaType::UrlEncoded => {
|
||||||
|
let bytes = Bytes::from_request(request, state)
|
||||||
|
.await
|
||||||
|
.map_err(extractor_rejection)?;
|
||||||
|
Self::parse_urlencoded(&bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum InteractionMediaType {
|
||||||
|
Multipart,
|
||||||
|
UrlEncoded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interaction_media_type(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<InteractionMediaType, InteractionFormRejection> {
|
||||||
|
let content_type = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.ok_or(InteractionFormRejection::UnsupportedMediaType)?;
|
||||||
|
match content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("multipart/form-data") => Ok(InteractionMediaType::Multipart),
|
||||||
|
Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded),
|
||||||
|
_ => Err(InteractionFormRejection::UnsupportedMediaType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection {
|
||||||
|
if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||||
|
InteractionFormRejection::BodyTooLarge
|
||||||
|
} else {
|
||||||
|
InteractionFormRejection::InvalidBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_urlencoded_pairs(
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
||||||
|
if body.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
body.split(|byte| *byte == b'&')
|
||||||
|
.map(|pair| {
|
||||||
|
let equals = pair.iter().position(|byte| *byte == b'=');
|
||||||
|
let (name, value) = match equals {
|
||||||
|
Some(index) => (&pair[..index], &pair[index + 1..]),
|
||||||
|
None => (pair, &[][..]),
|
||||||
|
};
|
||||||
|
Ok((percent_decode(name)?, percent_decode(value)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
||||||
|
let mut out = Vec::with_capacity(input.len());
|
||||||
|
let mut bytes = input.iter().copied();
|
||||||
|
while let Some(byte) = bytes.next() {
|
||||||
|
match byte {
|
||||||
|
b'+' => out.push(b' '),
|
||||||
|
b'%' => {
|
||||||
|
let high = bytes
|
||||||
|
.next()
|
||||||
|
.and_then(hex)
|
||||||
|
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
|
let low = bytes
|
||||||
|
.next()
|
||||||
|
.and_then(hex)
|
||||||
|
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
|
out.push(high * 16 + low);
|
||||||
|
}
|
||||||
|
byte => out.push(byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-343
@@ -11,6 +11,10 @@ use std::convert::Infallible;
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
mod forms;
|
||||||
|
|
||||||
|
pub use forms::{Form, FromInteractionForm, InteractionFile, InteractionForm, InteractionRequest};
|
||||||
|
|
||||||
pub const HEMX_PARTIAL_HEADER: &str = "x-hemx-partial";
|
pub const HEMX_PARTIAL_HEADER: &str = "x-hemx-partial";
|
||||||
pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint";
|
pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint";
|
||||||
pub const HEMX_TITLE_HEADER: &str = "x-hemx-title";
|
pub const HEMX_TITLE_HEADER: &str = "x-hemx-title";
|
||||||
@@ -134,78 +138,10 @@ pub struct EffectResponse {
|
|||||||
pub batch: EffectBatch,
|
pub batch: EffectBatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct InteractionFile {
|
|
||||||
pub name: String,
|
|
||||||
pub file_name: Option<String>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub bytes: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct InteractionForm {
|
|
||||||
pub handle_id: u32,
|
|
||||||
fields: Vec<(String, String)>,
|
|
||||||
files: Vec<InteractionFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A validated hemx mutation request.
|
|
||||||
///
|
|
||||||
/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are
|
|
||||||
/// accepted. Body size is intentionally host policy: apply Axum's
|
|
||||||
/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit)
|
|
||||||
/// to the mutation route; limit rejections become HTTP 413 before dispatch.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct InteractionRequest {
|
|
||||||
form: InteractionForm,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait DispatchRegistry {
|
pub trait DispatchRegistry {
|
||||||
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
|
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 {
|
pub trait FromHandlerState<S>: Sized {
|
||||||
fn from_handler_state(state: S) -> Self;
|
fn from_handler_state(state: S) -> Self;
|
||||||
}
|
}
|
||||||
@@ -320,142 +256,6 @@ impl<S> FromHandlerState<S> for State<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
handle_id,
|
|
||||||
fields: fields.into_iter().collect(),
|
|
||||||
files: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn for_handle<I>(
|
|
||||||
handle: Handle<I>,
|
|
||||||
fields: impl IntoIterator<Item = (String, String)>,
|
|
||||||
) -> Self {
|
|
||||||
Self::new(handle.id().id, fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
|
||||||
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn parse_multipart(
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<Self, InteractionFormRejection> {
|
|
||||||
let mut fields = Vec::new();
|
|
||||||
let mut files = Vec::new();
|
|
||||||
|
|
||||||
while let Some(field) = multipart
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|_| InteractionFormRejection::InvalidBody)?
|
|
||||||
{
|
|
||||||
let Some(name) = field.name().map(str::to_owned) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let file_name = field.file_name().map(str::to_owned);
|
|
||||||
let content_type = field.content_type().map(str::to_owned);
|
|
||||||
let bytes = field
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
|
||||||
|
|
||||||
if file_name.is_some() {
|
|
||||||
files.push(InteractionFile {
|
|
||||||
name,
|
|
||||||
file_name,
|
|
||||||
content_type,
|
|
||||||
bytes: bytes.to_vec(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let value = String::from_utf8(bytes.to_vec())
|
|
||||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
|
||||||
fields.push((name, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::from_parts(fields, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(
|
|
||||||
fields: Vec<(String, String)>,
|
|
||||||
files: Vec<InteractionFile>,
|
|
||||||
) -> Result<Self, InteractionFormRejection> {
|
|
||||||
let Some(handle) = fields
|
|
||||||
.iter()
|
|
||||||
.find_map(|(name, value)| (name == HEMX_HANDLE_FIELD).then_some(value))
|
|
||||||
else {
|
|
||||||
return Err(InteractionFormRejection::MissingHandle);
|
|
||||||
};
|
|
||||||
let handle_id = handle
|
|
||||||
.parse::<u32>()
|
|
||||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
|
||||||
Ok(Self {
|
|
||||||
handle_id,
|
|
||||||
fields,
|
|
||||||
files,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value(&self, name: &str) -> Option<&str> {
|
|
||||||
self.fields
|
|
||||||
.iter()
|
|
||||||
.find_map(|(field, value)| (field == name).then_some(value.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse<T>(&self, name: &str) -> Option<T>
|
|
||||||
where
|
|
||||||
T: std::str::FromStr,
|
|
||||||
{
|
|
||||||
self.value(name).and_then(|value| value.parse().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
|
||||||
self.fields
|
|
||||||
.iter()
|
|
||||||
.filter_map(move |(field, value)| (field == name).then_some(value.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fields(&self) -> &[(String, String)] {
|
|
||||||
&self.fields
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn files(&self) -> &[InteractionFile] {
|
|
||||||
&self.files
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
|
||||||
HandlerRegistry::new(fingerprint)
|
HandlerRegistry::new(fingerprint)
|
||||||
}
|
}
|
||||||
@@ -1228,93 +1028,6 @@ impl DispatchRegistry for HandlerRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for InteractionFormRejection {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
let (status, message) = match self {
|
|
||||||
Self::UnsupportedMediaType => (
|
|
||||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
|
|
||||||
),
|
|
||||||
Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"),
|
|
||||||
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"),
|
|
||||||
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"),
|
|
||||||
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"),
|
|
||||||
};
|
|
||||||
(status, message).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> FromRequest<S> for InteractionRequest
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = InteractionFormRejection;
|
|
||||||
|
|
||||||
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
InteractionForm::from_request(request, state)
|
|
||||||
.await
|
|
||||||
.map(|form| Self { form })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> FromRequest<S> for InteractionForm
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = InteractionFormRejection;
|
|
||||||
|
|
||||||
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
match interaction_media_type(request.headers())? {
|
|
||||||
InteractionMediaType::Multipart => {
|
|
||||||
let multipart = Multipart::from_request(request, state)
|
|
||||||
.await
|
|
||||||
.map_err(extractor_rejection)?;
|
|
||||||
Self::parse_multipart(multipart).await
|
|
||||||
}
|
|
||||||
InteractionMediaType::UrlEncoded => {
|
|
||||||
let bytes = Bytes::from_request(request, state)
|
|
||||||
.await
|
|
||||||
.map_err(extractor_rejection)?;
|
|
||||||
Self::parse_urlencoded(&bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
enum InteractionMediaType {
|
|
||||||
Multipart,
|
|
||||||
UrlEncoded,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn interaction_media_type(
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> Result<InteractionMediaType, InteractionFormRejection> {
|
|
||||||
let content_type = headers
|
|
||||||
.get(header::CONTENT_TYPE)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or(InteractionFormRejection::UnsupportedMediaType)?;
|
|
||||||
match content_type
|
|
||||||
.split(';')
|
|
||||||
.next()
|
|
||||||
.map(str::trim)
|
|
||||||
.map(str::to_ascii_lowercase)
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("multipart/form-data") => Ok(InteractionMediaType::Multipart),
|
|
||||||
Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded),
|
|
||||||
_ => Err(InteractionFormRejection::UnsupportedMediaType),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection {
|
|
||||||
if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE {
|
|
||||||
InteractionFormRejection::BodyTooLarge
|
|
||||||
} else {
|
|
||||||
InteractionFormRejection::InvalidBody
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PageMode {
|
impl PageMode {
|
||||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||||
match headers
|
match headers
|
||||||
@@ -1502,61 +1215,13 @@ fn base64_url_no_pad(input: &[u8]) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
|
||||||
if body.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
body.split(|byte| *byte == b'&')
|
|
||||||
.map(|pair| {
|
|
||||||
let equals = pair.iter().position(|byte| *byte == b'=');
|
|
||||||
let (name, value) = match equals {
|
|
||||||
Some(index) => (&pair[..index], &pair[index + 1..]),
|
|
||||||
None => (pair, &[][..]),
|
|
||||||
};
|
|
||||||
Ok((percent_decode(name)?, percent_decode(value)?))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
|
||||||
let mut out = Vec::with_capacity(input.len());
|
|
||||||
let mut bytes = input.iter().copied();
|
|
||||||
while let Some(byte) = bytes.next() {
|
|
||||||
match byte {
|
|
||||||
b'+' => out.push(b' '),
|
|
||||||
b'%' => {
|
|
||||||
let high = bytes
|
|
||||||
.next()
|
|
||||||
.and_then(hex)
|
|
||||||
.ok_or(InteractionFormRejection::InvalidBody)?;
|
|
||||||
let low = bytes
|
|
||||||
.next()
|
|
||||||
.and_then(hex)
|
|
||||||
.ok_or(InteractionFormRejection::InvalidBody)?;
|
|
||||||
out.push(high * 16 + low);
|
|
||||||
}
|
|
||||||
byte => out.push(byte),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex(byte: u8) -> Option<u8> {
|
|
||||||
match byte {
|
|
||||||
b'0'..=b'9' => Some(byte - b'0'),
|
|
||||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
|
||||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs,
|
base64_url_no_pad, encode_sse_batch,
|
||||||
percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection,
|
forms::{parse_urlencoded_pairs, percent_decode},
|
||||||
HEMX_SSE_EVENT,
|
html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
|
||||||
|
InteractionFormRejection, HEMX_SSE_EVENT,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{to_bytes, Body},
|
body::{to_bytes, Body},
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ use axum::{
|
|||||||
use hemx_axum::{
|
use hemx_axum::{
|
||||||
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
|
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
|
||||||
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
|
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
|
||||||
HandlerErrorContext, HandlerFailure, InteractionForm, InteractionFormRejection,
|
FormDecodeError, FromInteractionForm, HandlerErrorContext, HandlerFailure, InteractionForm,
|
||||||
InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE,
|
InteractionFormRejection, InteractionRequest, IntoHandlerFailure, PageMode, PageRequest,
|
||||||
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER,
|
PageResponse, HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER,
|
||||||
|
HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER,
|
||||||
};
|
};
|
||||||
use hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot};
|
use hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot};
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
@@ -38,6 +39,39 @@ async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TypedUpload {
|
||||||
|
title: String,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromInteractionForm for TypedUpload {
|
||||||
|
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
|
||||||
|
let title = form
|
||||||
|
.value("title")
|
||||||
|
.ok_or_else(|| FormDecodeError::new("missing title"))?;
|
||||||
|
let upload = form
|
||||||
|
.file("upload")
|
||||||
|
.ok_or_else(|| FormDecodeError::new("missing upload"))?;
|
||||||
|
Ok(Self {
|
||||||
|
title: title.to_owned(),
|
||||||
|
bytes: upload.bytes.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn typed_multipart_mutation(
|
||||||
|
request: InteractionRequest,
|
||||||
|
) -> Result<EffectResponse, DispatchRejection> {
|
||||||
|
request.dispatch(interactions(BuildFingerprint(4)).on_form(
|
||||||
|
Handle::<()>::new(7),
|
||||||
|
|upload: TypedUpload| {
|
||||||
|
assert_eq!(upload.title, "report");
|
||||||
|
assert_eq!(upload.bytes, 5);
|
||||||
|
Slot::<String>::new(3).text("accepted")
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
||||||
let _guard = BOUNDARY_TEST_LOCK.lock().await;
|
let _guard = BOUNDARY_TEST_LOCK.lock().await;
|
||||||
@@ -131,6 +165,25 @@ async fn interaction_boundary_extracts_multipart_fields_and_files() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let typed_response = Router::new()
|
||||||
|
.route("/typed-upload", post(typed_multipart_mutation))
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/typed-upload")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(typed_response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
typed_response.headers()[header::CONTENT_TYPE],
|
||||||
|
HEMX_CONTENT_TYPE
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+25
-1
@@ -231,7 +231,12 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
|||||||
let segments = path.path.segments.iter().collect::<Vec<_>>();
|
let segments = path.path.segments.iter().collect::<Vec<_>>();
|
||||||
let form = match segments.as_slice() {
|
let form = match segments.as_slice() {
|
||||||
[form] if form.ident == "Form" => form,
|
[form] if form.ident == "Form" => form,
|
||||||
[hemx, .., form] if hemx.ident == "hemx" && form.ident == "Form" => form,
|
[namespace, .., form]
|
||||||
|
if (namespace.ident == "hemx" || namespace.ident == "hemx_axum")
|
||||||
|
&& form.ident == "Form" =>
|
||||||
|
{
|
||||||
|
form
|
||||||
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let PathArguments::AngleBracketed(args) = &form.arguments else {
|
let PathArguments::AngleBracketed(args) = &form.arguments else {
|
||||||
@@ -928,6 +933,25 @@ mod tests {
|
|||||||
use quote::{quote, ToTokens};
|
use quote::{quote, ToTokens};
|
||||||
use syn::{parse_quote, ItemFn, Type};
|
use syn::{parse_quote, ItemFn, Type};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handler_form_model_accepts_documented_qualified_spellings() {
|
||||||
|
let local: ItemFn = parse_quote!(
|
||||||
|
fn save(form: Form<Project>) {}
|
||||||
|
);
|
||||||
|
let adapter: ItemFn = parse_quote!(
|
||||||
|
fn save(form: hemx_axum::Form<Project>) {}
|
||||||
|
);
|
||||||
|
let facade: ItemFn = parse_quote!(
|
||||||
|
fn save(form: hemx::Form<Project>) {}
|
||||||
|
);
|
||||||
|
|
||||||
|
for item in [&local, &adapter, &facade] {
|
||||||
|
let model = handler_form_model_type(item).expect("typed form model");
|
||||||
|
assert_eq!(model.to_token_stream().to_string(), "Project");
|
||||||
|
assert!(has_form_param(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_file_and_form_helpers_preserve_exact_contracts() {
|
fn generated_file_and_form_helpers_preserve_exact_contracts() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ cargo test -p hemx-build no_op_build_preserves_generated_artifact_timestamps --l
|
|||||||
cargo test -p hemx-build changed_template_refreshes_generated_artifacts --lib # spec: resource/005 check
|
cargo test -p hemx-build changed_template_refreshes_generated_artifacts --lib # spec: resource/005 check
|
||||||
cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check
|
cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check
|
||||||
cargo test -p hemx-derive --test compile_fail handler_macro_reports_unknown_handle_and_bad_shape -- --exact # spec: derive/002 check
|
cargo test -p hemx-derive --test compile_fail handler_macro_reports_unknown_handle_and_bad_shape -- --exact # spec: derive/002 check
|
||||||
|
cargo test -p hemx-derive handler_form_model_accepts_documented_qualified_spellings --lib # spec: assurance/002 check
|
||||||
cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check
|
cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check
|
||||||
cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check
|
cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check
|
||||||
cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check
|
cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check
|
||||||
cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check # spec: boundary/001 check
|
cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check # spec: boundary/001 check
|
||||||
cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check
|
cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check
|
||||||
cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check
|
cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check
|
||||||
cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check
|
cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check # spec: assurance/002 check
|
||||||
|
test -f hemx-axum/src/forms.rs && grep -q '^mod forms;$' hemx-axum/src/lib.rs && ! grep -Eq '^impl IntoResponse for InteractionFormRejection|^impl<S> FromRequest<S> for InteractionRequest|^fn parse_urlencoded_pairs\(' hemx-axum/src/lib.rs # spec: architecture/004 check
|
||||||
cargo test -p hemx-axum --test response effect_and_dispatch_responses_preserve_status_wire_and_diagnostics -- --exact # spec: axum/005 check
|
cargo test -p hemx-axum --test response effect_and_dispatch_responses_preserve_status_wire_and_diagnostics -- --exact # spec: axum/005 check
|
||||||
cargo test -p hemx-js --test runtime runtime_exposes_debug_api_before_startup_side_effects -- --exact # spec: runtime/002 check
|
cargo test -p hemx-js --test runtime runtime_exposes_debug_api_before_startup_side_effects -- --exact # spec: runtime/002 check
|
||||||
cargo test -p hemx-build generated_lowering_preserves_native_interaction_contract --lib # spec: html/001 check # spec: html/002 check # spec: html/003 check # spec: html/004 check
|
cargo test -p hemx-build generated_lowering_preserves_native_interaction_contract --lib # spec: html/001 check # spec: html/002 check # spec: html/003 check # spec: html/004 check
|
||||||
|
|||||||
Reference in New Issue
Block a user