Skip to content

Commit 4030b6c

Browse files
committed
Optimize
1 parent 50f48aa commit 4030b6c

55 files changed

Lines changed: 2150 additions & 1465 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ node_modules
2626

2727
# Generated OpenAPI artifacts at workspace root
2828
/openapi*.json
29+
.retry-now/

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,10 @@ pub struct CategoryInArticle {
656656

657657
schema_type!(
658658
ArticleResponse from crate::models::article::Model,
659+
relation_adapters = [
660+
("user", UserInArticle),
661+
("category", CategoryInArticle),
662+
],
659663
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
660664
);
661665

@@ -670,14 +674,14 @@ Ok(ArticleResponse {
670674

671675
How it works:
672676

673-
- `schema_type!` looks for same-file DTOs named `{RelationNamePascal}In{ResponseBase}`
674-
- `user` on `ArticleResponse``UserInArticle`
675-
- `category` on `ArticleResponse``CategoryInArticle`
676-
- It generates local compile adapters so `Option<Model>.into()` works unchanged in the handler
677+
- `relation_adapters = [("field", AdapterStruct)]` is an explicit opt-in for single-value relation fields (`HasOne` / `BelongsTo`)
678+
- The adapter struct name is used verbatim; Vespera does not infer adapter names by convention
679+
- If an explicitly named adapter struct cannot be found in the same file, compilation fails instead of silently falling back
680+
- Vespera generates local compile adapters so `Option<Model>.into()` works unchanged in the handler
677681
- The internal `__Vespera…Relation` wrapper type stays private to Rust typing
678682
- OpenAPI references the **adapter DTO's own schema** (`UserInArticle`, `CategoryInArticle`) — so the documented response shape matches exactly what the handler serializes, instead of over-promising the base relation schema (`UserSchema`, `CategorySchema`)
679683

680-
Use this when you want route-local response DTOs for single-value relations (`HasOne` / `BelongsTo`) without rewriting the route construction logic.
684+
Use this when you want route-local response DTOs for single-value relations without rewriting the route construction logic. Relation fields not listed in `relation_adapters` keep the default base-schema relation type.
681685

682686
### Multipart Mode
683687

SKILL.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,10 @@ pub struct CategoryInArticle {
531531

532532
schema_type!(
533533
ArticleResponse from crate::models::article::Model,
534+
relation_adapters = [
535+
("user", UserInArticle),
536+
("category", CategoryInArticle),
537+
],
534538
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
535539
);
536540

@@ -545,11 +549,12 @@ Ok(ArticleResponse {
545549
Rules:
546550

547551
- Only applies to single-value relations (`HasOne` / `BelongsTo`)
548-
- The local DTO name must follow `{RelationNamePascal}In{ResponseBase}`
549-
- `user` on `ArticleResponse``UserInArticle`
550-
- `category` on `ArticleResponse``CategoryInArticle`
552+
- Must be opted in explicitly with `relation_adapters = [("field", AdapterStruct)]`
553+
- The adapter struct name is used verbatim; Vespera does not infer adapter names by convention
554+
- Missing explicitly named adapter structs are compile errors
551555
- Vespera generates local compile adapters so `Option<Model>.into()` works without changing the route
552-
- The adapter wrapper is hidden from OpenAPI; the spec still references the original related schema (`UserSchema`, `CategorySchema`)
556+
- OpenAPI references the adapter DTO's own schema (`UserInArticle`, `CategoryInArticle`), honoring any `#[schema(name = "...")]` override
557+
- Single-value relations not listed in `relation_adapters` keep the default base-schema relation type
553558
- `HasMany` relations remain excluded by default unless explicitly `pick`ed or `add`ed
554559

555560
### Complete Example

crates/vespera/src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@ pub use vespera_core::openapi::OpenApi;
2222
// Re-export macros from vespera_macro
2323
pub use vespera_macro::{Multipart, Schema, cron, export_app, route, schema, schema_type, vespera};
2424

25+
/// Marker trait implemented by every `#[derive(Schema)]` type.
26+
///
27+
/// The derive macro auto-implements this trait, which is intentionally
28+
/// empty — it carries no methods. Its purpose is to anchor the
29+
/// compile-time leaf-type assertions emitted by `#[derive(Schema)]`:
30+
/// for every field whose type is not a builtin OpenAPI primitive,
31+
/// not `serde_json::Value`, and not marked `#[schema(any)]`, the
32+
/// derive emits a `T: ::vespera::Schema` bound assertion against the
33+
/// field's leaf type. An unbound leaf — typically a custom struct
34+
/// that forgot its own `#[derive(Schema)]` — becomes a compile error
35+
/// at the field site instead of silently emitting `{type:object}`
36+
/// into the OpenAPI document.
37+
///
38+
/// Users normally never name this trait directly — `#[derive(Schema)]`
39+
/// is the entire user surface. If you intentionally want a field to
40+
/// stay as opaque `{type:object}` (arbitrary JSON), mark it with
41+
/// `#[schema(any)]` to skip the assertion AND lock the schema to
42+
/// `object`. `serde_json::Value` fields are allowlisted automatically.
43+
///
44+
/// The trait and the `vespera::Schema` derive macro share the same
45+
/// name but live in different namespaces (trait vs. derive-macro), so
46+
/// the existing `#[derive(Schema)]` syntax continues to work
47+
/// unchanged.
48+
pub trait Schema {}
49+
2550
// Re-export serde_json for merge feature (runtime spec merging)
2651
pub use serde_json;
2752

crates/vespera/src/multipart.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ impl<'a> MeteredField<'a> {
419419

420420
/// Read the whole field into owned bytes, metering every chunk against the
421421
/// aggregate cap.
422-
pub async fn bytes(self) -> Result<axum::body::Bytes, TypedMultipartError> {
422+
pub async fn bytes(mut self) -> Result<axum::body::Bytes, TypedMultipartError> {
423423
self.bytes_with_limit_inner(None, 0)
424424
.await
425425
.map(axum::body::Bytes::from)
@@ -430,7 +430,7 @@ impl<'a> MeteredField<'a> {
430430
/// The limit is checked before copying each chunk into the accumulator, and
431431
/// every chunk is still counted against the request-wide aggregate cap.
432432
pub async fn bytes_with_limit(
433-
self,
433+
mut self,
434434
limit_bytes: usize,
435435
initial_capacity: usize,
436436
) -> Result<axum::body::Bytes, TypedMultipartError> {
@@ -440,7 +440,7 @@ impl<'a> MeteredField<'a> {
440440
}
441441

442442
async fn bytes_with_limit_inner(
443-
mut self,
443+
&mut self,
444444
limit: Option<usize>,
445445
initial_capacity: usize,
446446
) -> Result<Vec<u8>, TypedMultipartError> {
@@ -848,20 +848,24 @@ where
848848
///
849849
/// When a limit is set the cumulative size is checked after each chunk
850850
/// and an over-limit chunk is rejected *before* it is copied in.
851+
struct FieldBytes<'a> {
852+
field: MeteredField<'a>,
853+
data: Vec<u8>,
854+
}
855+
851856
async fn read_field_data(
852-
field: MeteredField<'_>,
857+
mut field: MeteredField<'_>,
853858
limit: Option<usize>,
854859
initial_capacity: usize,
855-
) -> Result<(String, Vec<u8>), TypedMultipartError> {
860+
) -> Result<FieldBytes<'_>, TypedMultipartError> {
856861
// Part counting now happens once per part in the derived loop
857862
// (`register_multipart_part`), so the field parsers no longer count.
858863
// Initial capacity is independent from the hard byte limit: tiny scalar
859864
// fields keep the 256B cap without preallocating 256B per bool/number.
860-
let field_name = field.name().unwrap_or_default().to_string();
861865
let buf = field
862866
.bytes_with_limit_inner(limit, initial_capacity)
863867
.await?;
864-
Ok((field_name, buf))
868+
Ok(FieldBytes { field, data: buf })
865869
}
866870

867871
/// Default cap for tiny scalar multipart fields when no explicit

crates/vespera/src/multipart/scalar_parsers.rs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for String {
2525
// `Some(usize::MAX)` (set by the derive macro) and stays unbounded;
2626
// an explicit byte size wins as `Some(n)`.
2727
let limit = limit_bytes.unwrap_or(DEFAULT_STRING_FIELD_LIMIT_BYTES);
28-
let (field_name, data) =
29-
read_field_data(field, Some(limit), STRING_INITIAL_CAPACITY_BYTES).await?;
28+
let field_data = read_field_data(field, Some(limit), STRING_INITIAL_CAPACITY_BYTES).await?;
29+
let super::FieldBytes { field, data } = field_data;
3030
Self::from_utf8(data).map_err(|e| TypedMultipartError::WrongFieldType {
31-
field_name,
31+
field_name: field.name().unwrap_or_default().to_string(),
3232
wanted: Cow::Borrowed("String"),
3333
source: e.to_string(),
3434
})
@@ -43,19 +43,20 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for bool {
4343
limit_bytes: Option<usize>,
4444
_state: &S,
4545
) -> Result<Self, TypedMultipartError> {
46-
let (field_name, data) = read_field_data(
46+
let field_data = read_field_data(
4747
field,
4848
Some(tiny_scalar_limit(limit_bytes)),
4949
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
5050
)
5151
.await?;
52+
let super::FieldBytes { field, data } = field_data;
5253
let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType {
53-
field_name: field_name.clone(),
54+
field_name: field.name().unwrap_or_default().to_string(),
5455
wanted: Cow::Borrowed("bool"),
5556
source: e.to_string(),
5657
})?;
5758
str_to_bool(text).ok_or_else(|| TypedMultipartError::WrongFieldType {
58-
field_name,
59+
field_name: field.name().unwrap_or_default().to_string(),
5960
wanted: Cow::Borrowed("bool"),
6061
source: format!(
6162
"invalid boolean value: `{}`",
@@ -76,21 +77,22 @@ macro_rules! impl_try_from_field_for_number {
7677
limit_bytes: Option<usize>,
7778
_state: &S,
7879
) -> Result<Self, TypedMultipartError> {
79-
let (field_name, data) = read_field_data(
80+
let field_data = read_field_data(
8081
field,
8182
Some(tiny_scalar_limit(limit_bytes)),
8283
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
8384
).await?;
85+
let super::FieldBytes { field, data } = field_data;
8486
let text = std::str::from_utf8(&data).map_err(|e| {
8587
TypedMultipartError::WrongFieldType {
86-
field_name: field_name.clone(),
88+
field_name: field.name().unwrap_or_default().to_string(),
8789
wanted: Cow::Borrowed(stringify!($ty)),
8890
source: e.to_string(),
8991
}
9092
})?;
9193
text.trim().parse::<$ty>().map_err(|e| {
9294
TypedMultipartError::WrongFieldType {
93-
field_name,
95+
field_name: field.name().unwrap_or_default().to_string(),
9496
wanted: Cow::Borrowed(stringify!($ty)),
9597
source: e.to_string(),
9698
}
@@ -113,22 +115,23 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for char {
113115
limit_bytes: Option<usize>,
114116
_state: &S,
115117
) -> Result<Self, TypedMultipartError> {
116-
let (field_name, data) = read_field_data(
118+
let field_data = read_field_data(
117119
field,
118120
Some(tiny_scalar_limit(limit_bytes)),
119121
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
120122
)
121123
.await?;
124+
let super::FieldBytes { field, data } = field_data;
122125
let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType {
123-
field_name: field_name.clone(),
126+
field_name: field.name().unwrap_or_default().to_string(),
124127
wanted: Cow::Borrowed("char"),
125128
source: e.to_string(),
126129
})?;
127130
let mut chars = text.chars();
128131
match (chars.next(), chars.next()) {
129132
(Some(c), None) => Ok(c),
130133
_ => Err(TypedMultipartError::WrongFieldType {
131-
field_name,
134+
field_name: field.name().unwrap_or_default().to_string(),
132135
wanted: Cow::Borrowed("char"),
133136
source: "expected exactly one character".to_string(),
134137
}),

crates/vespera/src/validated.rs

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
3131
use ::axum::{
3232
Json,
33-
extract::{FromRequest, Request},
34-
http::{StatusCode, header::CONTENT_TYPE},
33+
extract::{FromRequest, FromRequestParts, Request},
34+
http::{HeaderValue, StatusCode, header::CONTENT_TYPE, request::Parts},
3535
response::{IntoResponse, Response},
3636
};
3737
use ::garde::Validate;
@@ -185,65 +185,44 @@ impl<C, T> DerefMut for ValidatedWith<C, T> {
185185
}
186186
}
187187

188-
impl<U> ValidatePayload for Json<U>
188+
impl<T> ValidatePayload for T
189189
where
190-
U: Validate<Context = ()>,
190+
T: ValidatePayloadWith<()>,
191191
{
192-
type Inner = U;
193-
fn payload(&self) -> &U {
194-
&self.0
195-
}
196-
}
192+
type Inner = <T as ValidatePayloadWith<()>>::Inner;
197193

198-
impl<U> ValidatePayload for ::axum::Form<U>
199-
where
200-
U: Validate<Context = ()>,
201-
{
202-
type Inner = U;
203-
fn payload(&self) -> &U {
204-
&self.0
194+
fn payload(&self) -> &Self::Inner {
195+
<Self as ValidatePayloadWith<()>>::payload(self)
205196
}
206197
}
207198

208-
impl<U> ValidatePayload for ::axum::extract::Query<U>
209-
where
210-
U: Validate<Context = ()>,
211-
{
212-
type Inner = U;
213-
fn payload(&self) -> &U {
214-
&self.0
215-
}
216-
}
217-
218-
impl<U> ValidatePayload for ::axum::extract::Path<U>
199+
impl<S, T> FromRequest<S> for Validated<T>
219200
where
220-
U: Validate<Context = ()>,
201+
S: Send + Sync,
202+
T: FromRequest<S> + ValidatePayload + Send,
221203
{
222-
type Inner = U;
223-
fn payload(&self) -> &U {
224-
&self.0
225-
}
226-
}
204+
type Rejection = Response;
227205

228-
impl<U> ValidatePayload for crate::multipart::TypedMultipart<U>
229-
where
230-
U: Validate<Context = ()>,
231-
{
232-
type Inner = U;
233-
fn payload(&self) -> &U {
234-
&self.0
206+
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
207+
let extracted = T::from_request(req, state)
208+
.await
209+
.map_err(IntoResponse::into_response)?;
210+
match extracted.payload().validate() {
211+
Ok(()) => Ok(Self(extracted)),
212+
Err(report) => Err(build_validation_response(&report)),
213+
}
235214
}
236215
}
237216

238-
impl<S, T> FromRequest<S> for Validated<T>
217+
impl<S, T> FromRequestParts<S> for Validated<T>
239218
where
240219
S: Send + Sync,
241-
T: FromRequest<S> + ValidatePayload + Send,
220+
T: FromRequestParts<S> + ValidatePayload + Send,
242221
{
243222
type Rejection = Response;
244223

245-
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
246-
let extracted = T::from_request(req, state)
224+
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
225+
let extracted = T::from_request_parts(parts, state)
247226
.await
248227
.map_err(IntoResponse::into_response)?;
249228
match extracted.payload().validate() {
@@ -275,6 +254,28 @@ where
275254
}
276255
}
277256

257+
impl<S, C, T> FromRequestParts<S> for ValidatedWith<C, T>
258+
where
259+
S: Send + Sync + ValidationContext<C>,
260+
C: Send + Sync + 'static,
261+
T: FromRequestParts<S> + ValidatePayloadWith<C> + Send,
262+
{
263+
type Rejection = Response;
264+
265+
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
266+
let extracted = T::from_request_parts(parts, state)
267+
.await
268+
.map_err(IntoResponse::into_response)?;
269+
match extracted
270+
.payload()
271+
.validate_with(state.validation_context())
272+
{
273+
Ok(()) => Ok(Self::new(extracted)),
274+
Err(report) => Err(build_validation_response(&report)),
275+
}
276+
}
277+
}
278+
278279
/// Build the canonical `422 Unprocessable Entity` response from a
279280
/// [`garde::Report`].
280281
///
@@ -374,10 +375,10 @@ fn build_validation_response(report: &::garde::Report) -> Response {
374375
br#"{"errors":[{"message":"request validation failed","path":""}]}"#.to_vec()
375376
});
376377

377-
let mut response = (StatusCode::UNPROCESSABLE_ENTITY, body).into_response();
378-
response.headers_mut().insert(
379-
CONTENT_TYPE,
380-
::axum::http::HeaderValue::from_static("application/json"),
381-
);
382-
response
378+
(
379+
StatusCode::UNPROCESSABLE_ENTITY,
380+
[(CONTENT_TYPE, HeaderValue::from_static("application/json"))],
381+
body,
382+
)
383+
.into_response()
383384
}

0 commit comments

Comments
 (0)