perf: round 13 — grouped-view virtualization, notification/login query narrowing, HTTP dedup, locale precompute
Benchmark-gated (BEFORE/AFTER + equivalence/safety gate per change), same discipline as rounds 2-12. Full write-up in benches/ROUND13.md. Shipped: - V1 Grouped views windowed (files route + ResourceList). The grid arm was the last unwindowed path (trash is grouped-by-default in grid): each swimlane now feeds its own VirtualList, outer container a flex stack. vitest gate: 800-item grouped grid mounts <120 .file-item (was 800). - Q1 get_users_by_ids drops the <=512 KiB avatar image + ui_preferences JSONB (notification path never reads them). 30-member fan-out 8.60 -> 0.25 ms (34.3x), ~7.7 MB off the wire. - Q2 Login provisioning is_empty() -> SELECT EXISTS for calendar + address book (every login). 0.193 -> 0.170 ms, widens with owned-row count. - Q3 Recent-access prunes only when the upsert inserted (RETURNING xmax=0) — a re-access can't grow the set. 0.567 -> 0.324 ms (1.75x). - L1 Locale supported-codes precomputed once vs rebuilt per anonymous request. 616 -> 17.3 ns (35.7x), 18 -> 1 allocs. - H1 Duplicate /api TraceLayer removed (global stack already wraps it). 1.86 -> 1.42 us/request, -6 allocs. - H2 client_ip span field: borrow-only ClientIpDisplay vs owned String. 187 -> 173 ns, -1 alloc. Not shipped (discipline): the "media hooks read the blob 3x" lead was a correctness bug, not a perf dup — the raw-path metadata/faces readers resolve only for local+unencrypted+single-chunk blobs and silently produce nothing otherwise. Flagged for maintainers; routing through read_blob_bytes is a correctness fix (perf-neutral-to-negative), not a benchmark-gated perf change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
This commit is contained in:
@@ -41,7 +41,11 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
|
||||
async fn get_recent_items(&self, user_id: Uuid, limit: i32) -> Result<Vec<RecentItemDto>>;
|
||||
|
||||
/// Records/updates access to an item (upsert by user+item+type).
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<()>;
|
||||
/// Returns `true` when a NEW row was inserted (the recent set grew) and
|
||||
/// `false` when an existing row's timestamp was merely refreshed — the
|
||||
/// caller prunes only in the former case, since a re-access can never
|
||||
/// push the user over the cap (benches/ROUND13.md §Q3).
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
/// Removes an item from recents. Returns `true` if it existed.
|
||||
async fn remove_item(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
@@ -474,18 +474,21 @@ impl DefaultCalendarLifecycleHook {
|
||||
// Ownership-based idempotency check (see hook docstring for
|
||||
// the design rationale). Whether the existing calendar was
|
||||
// auto-provisioned by a prior run, manually created by the
|
||||
// user, or migrated in, we respect it and skip.
|
||||
let existing = self
|
||||
// user, or migrated in, we respect it and skip. `EXISTS`
|
||||
// short-circuits at the first owned row instead of hydrating them
|
||||
// all just to test emptiness — this runs on EVERY login
|
||||
// (benches/ROUND13.md §Q2).
|
||||
let has_calendar = self
|
||||
.calendar_storage
|
||||
.list_calendars_by_owner(user.id())
|
||||
.has_owned_calendar(user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"DefaultCalendarHook",
|
||||
format!("list_calendars_by_owner: {e}"),
|
||||
format!("has_owned_calendar: {e}"),
|
||||
)
|
||||
})?;
|
||||
if !existing.is_empty() {
|
||||
if has_calendar {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -1219,7 +1219,6 @@ impl ContactUseCase for ContactService {
|
||||
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::address_book_repository::AddressBookRepository;
|
||||
use crate::infrastructure::repositories::pg::AddressBookPgRepository;
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -1264,17 +1263,20 @@ impl DefaultAddressBookLifecycleHook {
|
||||
// Ownership-based idempotency check — same rationale as the
|
||||
// calendar hook. Any existing owned address book (auto-
|
||||
// provisioned earlier, user-created, migrated) is respected.
|
||||
let existing = self
|
||||
// `EXISTS` short-circuits instead of hydrating every owned
|
||||
// address book to test emptiness, on EVERY login
|
||||
// (benches/ROUND13.md §Q2).
|
||||
let has_address_book = self
|
||||
.address_book_repo
|
||||
.get_address_books_by_owner(user.id())
|
||||
.has_owned_address_book(user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"DefaultAddressBookHook",
|
||||
format!("get_address_books_by_owner: {e}"),
|
||||
format!("has_owned_address_book: {e}"),
|
||||
)
|
||||
})?;
|
||||
if !existing.is_empty() {
|
||||
if has_address_book {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,15 @@ impl RecentService {
|
||||
));
|
||||
}
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
// Prune only when the upsert actually inserted a NEW row — a
|
||||
// re-access refreshes an existing row's timestamp and can never
|
||||
// grow the set past the cap, so the prune (a DELETE over an
|
||||
// OFFSET self-subquery) is a wasted round-trip on that common path
|
||||
// (benches/ROUND13.md §Q3).
|
||||
let inserted = self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
if inserted {
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,13 @@ pub struct LocaleRegistry {
|
||||
/// case-insensitive: input is canonicalised, then probed against
|
||||
/// this set.
|
||||
canonical: Arc<HashSet<SmolStr>>,
|
||||
/// The same codes as an owned `Vec<String>`, materialized ONCE at
|
||||
/// [`Self::discover`] time. The `Accept-Language` extractor needs a
|
||||
/// `&[&str]` supported-list per anonymous request; without this it
|
||||
/// rebuilt N heap `String`s from the registry on every such request
|
||||
/// (the ROUND10 §15 "process-invariant rebuilt per request" class;
|
||||
/// benches/ROUND13.md §L1). Borrowed via [`Self::supported_codes`].
|
||||
supported_codes: Arc<Vec<String>>,
|
||||
/// The configured fallback locale. Resolved from
|
||||
/// `OXICLOUD_DEFAULT_LOCALE` at startup; defaults to English when
|
||||
/// unset.
|
||||
@@ -200,8 +207,15 @@ impl LocaleRegistry {
|
||||
sorted.join(", ")
|
||||
);
|
||||
|
||||
// Materialize the supported-codes list once. Order is irrelevant —
|
||||
// `accept_language::intersection` ranks by the request header's
|
||||
// q-values, not by this list's order.
|
||||
let supported_codes: Vec<String> =
|
||||
canonical.iter().map(|s| s.as_str().to_string()).collect();
|
||||
|
||||
Ok(Self {
|
||||
canonical: Arc::new(canonical),
|
||||
supported_codes: Arc::new(supported_codes),
|
||||
default,
|
||||
})
|
||||
}
|
||||
@@ -236,6 +250,13 @@ impl LocaleRegistry {
|
||||
self.canonical.iter().map(|s| Locale(s.clone()))
|
||||
}
|
||||
|
||||
/// The registry's codes as a borrowable `&[String]`, precomputed at
|
||||
/// [`Self::discover`] time. Feeds the per-request `Accept-Language`
|
||||
/// negotiation without re-allocating the list (benches/ROUND13.md §L1).
|
||||
pub fn supported_codes(&self) -> &[String] {
|
||||
&self.supported_codes
|
||||
}
|
||||
|
||||
/// Number of locales in the registry. Used by tests + startup logs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.canonical.len()
|
||||
|
||||
@@ -39,6 +39,14 @@ impl CalendarStorageAdapter {
|
||||
event_repository,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegates to [`CalendarPgRepository::has_owned_calendar`] — the
|
||||
/// `EXISTS` short-circuit used by the login provisioning hook instead
|
||||
/// of hydrating every owned calendar to test emptiness
|
||||
/// (benches/ROUND13.md §Q2).
|
||||
pub async fn has_owned_calendar(&self, owner_id: Uuid) -> Result<bool, DomainError> {
|
||||
self.calendar_repository.has_owned_calendar(owner_id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
|
||||
@@ -16,6 +16,23 @@ impl AddressBookPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// `EXISTS` short-circuit for the login provisioning hook — the old
|
||||
/// `get_address_books_by_owner(..).is_empty()` hydrated every owned
|
||||
/// `AddressBook` row on EVERY login just to test emptiness (the ROUND9
|
||||
/// §7 COUNT→EXISTS pattern; benches/ROUND13.md §Q2).
|
||||
pub async fn has_owned_address_book(&self, owner_id: Uuid) -> Result<bool, DomainError> {
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM carddav.address_books WHERE owner_id = $1)",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to probe owned address books: {}", e))
|
||||
})?;
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressBookRepository for AddressBookPgRepository {
|
||||
|
||||
@@ -16,6 +16,24 @@ impl CalendarPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// `EXISTS` short-circuit for the login provisioning hook, which only
|
||||
/// needs to know whether the user owns ANY calendar. The old
|
||||
/// `list_calendars_by_owner(..).is_empty()` hydrated every owned
|
||||
/// `Calendar` row (8 cols incl. description/color TEXT) on EVERY login
|
||||
/// just to test emptiness — the ROUND9 §7 `Drive::is_empty` COUNT→EXISTS
|
||||
/// pattern (benches/ROUND13.md §Q2).
|
||||
pub async fn has_owned_calendar(&self, owner_id: Uuid) -> CalendarRepositoryResult<bool> {
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM caldav.calendars WHERE owner_id = $1)")
|
||||
.bind(owner_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to probe owned calendars: {}", e))
|
||||
})?;
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarRepository for CalendarPgRepository {
|
||||
|
||||
@@ -96,19 +96,24 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
// `xmax = 0` on the affected row is the canonical upsert idiom for
|
||||
// "this was an INSERT, not a DO UPDATE" — lets the caller skip the
|
||||
// prune round-trip on the common re-access (UPDATE) path
|
||||
// (benches/ROUND13.md §Q3).
|
||||
let inserted: bool = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at)
|
||||
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id, item_id, item_type)
|
||||
DO UPDATE SET accessed_at = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.execute(&*self.db_pool)
|
||||
.fetch_one(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error upserting recent item access: {}", e);
|
||||
@@ -119,7 +124,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
async fn remove_item(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
|
||||
@@ -414,6 +414,16 @@ impl UserRepository for UserPgRepository {
|
||||
/// recipient expansion). Missing ids are silently skipped — the
|
||||
/// caller treats absent rows as "no such recipient", same as
|
||||
/// `get_user_by_id` returning `NotFound` for a single lookup.
|
||||
///
|
||||
/// Notification-recipient projection: the up-to-512 KiB avatar `image`
|
||||
/// and the `ui_preferences` JSONB are NOT hydrated (both come back as
|
||||
/// `None`/`Null`) — the sole caller
|
||||
/// (`RecipientNotificationService`) reads only the email/eligibility
|
||||
/// fields, and a group fan-out of M members otherwise detoasted +
|
||||
/// shipped + parsed M avatars purely to discard them (the ROUND12 §Q1
|
||||
/// avatar-narrowing pattern; benches/ROUND13.md §Q1). If a future
|
||||
/// caller needs the avatar, add a wide sibling rather than widening
|
||||
/// this one back.
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -425,9 +435,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
oidc_provider, oidc_subject, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
@@ -460,14 +469,14 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
None, // image — not projected (notification-recipient path)
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
serde_json::Value::Null, // ui_preferences — not projected
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
|
||||
@@ -10,7 +10,6 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
/// Liveness probe — returns 200 if the process is running, no DB check.
|
||||
@@ -672,12 +671,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// them on every overlapping request.
|
||||
router = router.route("/{*rest}", any(api_not_found));
|
||||
|
||||
// Compression is applied once, globally, in `main.rs` with a content-type
|
||||
// aware predicate that skips already-compressed media. Re-applying it here
|
||||
// would double-wrap `/api`: this inner layer (no predicate) would compress
|
||||
// media downloads, burning CPU for ~0 gain and stripping `Content-Length`.
|
||||
// So this router only adds tracing; compression is the global layer's job.
|
||||
router.layer(TraceLayer::new_for_http())
|
||||
// No per-router layers: the global `TraceLayer` + request-id stack in
|
||||
// `main.rs` wraps the whole app (this `/api` router is nested into it),
|
||||
// so a second `TraceLayer` here just double-wrapped every `/api`
|
||||
// request in a redundant span + response-future poll (benches/ROUND13.md
|
||||
// §H1). Compression is likewise the global layer's job — re-applying it
|
||||
// here (no predicate) would compress media downloads, burning CPU for
|
||||
// ~0 gain and stripping `Content-Length`.
|
||||
router
|
||||
}
|
||||
|
||||
/// Catch-all 404 for unknown `/api/*` paths. Pure log-anchoring
|
||||
|
||||
@@ -64,9 +64,15 @@ impl FromRequestParts<Arc<AppState>> for RequestLocale {
|
||||
.get(axum::http::header::ACCEPT_LANGUAGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
let supported_owned: Vec<String> =
|
||||
registry.iter().map(|l| l.as_str().to_string()).collect();
|
||||
let supported: Vec<&str> = supported_owned.iter().map(String::as_str).collect();
|
||||
// Borrow the precomputed supported-codes list (materialized
|
||||
// once at registry build) instead of rebuilding N heap Strings
|
||||
// per anonymous request (benches/ROUND13.md §L1). Only the
|
||||
// `&[&str]` view the crate needs is built here.
|
||||
let supported: Vec<&str> = registry
|
||||
.supported_codes()
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if let Some(matched) = accept_language::intersection(header_value, &supported).first()
|
||||
&& let Some(locale) = registry.parse(matched)
|
||||
{
|
||||
|
||||
@@ -92,7 +92,11 @@ pub struct ClientIpMakeSpan;
|
||||
|
||||
impl<B> MakeSpan<B> for ClientIpMakeSpan {
|
||||
fn make_span(&mut self, request: &axum::http::Request<B>) -> Span {
|
||||
let ip = super::trusted_proxy::client_ip(request, true);
|
||||
// Borrow-only IP resolution: the span records `client_ip` via `%ip`
|
||||
// (Display), so a `ClientIpDisplay` that renders straight into the
|
||||
// span's field storage avoids the per-request `String` the owned
|
||||
// `client_ip()` allocated (benches/ROUND13.md §H2).
|
||||
let ip = super::trusted_proxy::client_ip_display(request, true);
|
||||
let request_id = request
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
|
||||
@@ -146,6 +146,82 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
|
||||
client_ip_from_parts(req.headers(), peer, include_port)
|
||||
}
|
||||
|
||||
/// A resolved client-IP source that borrows from the request instead of
|
||||
/// allocating a `String`. [`std::fmt::Display`] renders it directly into the
|
||||
/// caller's buffer (the tracing span's field storage), so the per-request
|
||||
/// span factory no longer materializes an intermediate `String` on every
|
||||
/// request (benches/ROUND13.md §H2). Bytes rendered are identical to
|
||||
/// [`client_ip`]/[`client_ip_from_parts`] for all four cases.
|
||||
pub enum ClientIpDisplay<'a> {
|
||||
/// Proxy-forwarded client address (borrowed from `X-Forwarded-For` /
|
||||
/// `X-Real-Ip`), already trimmed.
|
||||
Forwarded(&'a str),
|
||||
/// Direct TCP peer, rendered with the port.
|
||||
PeerWithPort(SocketAddr),
|
||||
/// Direct TCP peer, rendered as the bare IP.
|
||||
PeerIp(IpAddr),
|
||||
/// No connection info available.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientIpDisplay<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientIpDisplay::Forwarded(s) => f.write_str(s),
|
||||
ClientIpDisplay::PeerWithPort(addr) => write!(f, "{addr}"),
|
||||
ClientIpDisplay::PeerIp(ip) => write!(f, "{ip}"),
|
||||
ClientIpDisplay::Unknown => f.write_str("unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-allocation twin of [`client_ip_from_parts`]: resolves the client-IP
|
||||
/// source without producing an owned `String`. The returned value borrows
|
||||
/// `headers`, so it must be `Display`-rendered before `headers` is dropped
|
||||
/// (the span factory does this synchronously).
|
||||
pub fn client_ip_display_from_parts<'a>(
|
||||
headers: &'a axum::http::HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
include_port: bool,
|
||||
) -> ClientIpDisplay<'a> {
|
||||
if let Some(peer_addr) = peer {
|
||||
if is_trusted_proxy(peer_addr.ip()) {
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(ip) = xff
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return ClientIpDisplay::Forwarded(ip);
|
||||
}
|
||||
if let Some(xri) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return ClientIpDisplay::Forwarded(xri);
|
||||
}
|
||||
}
|
||||
return if include_port {
|
||||
ClientIpDisplay::PeerWithPort(peer_addr)
|
||||
} else {
|
||||
ClientIpDisplay::PeerIp(peer_addr.ip())
|
||||
};
|
||||
}
|
||||
ClientIpDisplay::Unknown
|
||||
}
|
||||
|
||||
/// Zero-allocation twin of [`client_ip`] for the request-span factory.
|
||||
pub fn client_ip_display<B>(req: &Request<B>, include_port: bool) -> ClientIpDisplay<'_> {
|
||||
let peer: Option<SocketAddr> = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0);
|
||||
client_ip_display_from_parts(req.headers(), peer, include_port)
|
||||
}
|
||||
|
||||
/// Same as [`client_ip`], but operates on already-extracted parts (headers
|
||||
/// plus an optional TCP peer). Handlers that don't take a full `Request<B>`,
|
||||
/// e.g. those that consume the body via `Json<…>`, can still derive a stable
|
||||
|
||||
Reference in New Issue
Block a user