@@ -86,6 +86,16 @@ pub struct RequestUris {
8686 routed : Option < Uri > ,
8787}
8888
89+ /// Request extension caching the standalone rendering of the request's templated
90+ /// path-and-query, produced once by
91+ /// [`HttpRequestBuilder::build`][crate::HttpRequestBuilder::build].
92+ ///
93+ /// Routing only ever swaps the [`BaseUri`], never the path, so
94+ /// [`Router::resolve_request_uri`] reuses this rendering to join the resolved base without
95+ /// re-rendering the template - on the first attempt and on every retry/hedge attempt.
96+ #[ derive( Clone , Debug ) ]
97+ pub ( crate ) struct RenderedPath ( pub ( crate ) http:: uri:: PathAndQuery ) ;
98+
8999impl RequestUris {
90100 /// Creates a [`RequestUris`] with the given templated [`Uri`] as the
91101 /// [`original`](Self::original) and no [`routed`](Self::routed) yet.
@@ -263,23 +273,50 @@ impl Router {
263273 /// - [`Router::resolve_uri`] fails (e.g., a [`BaseUriConflict::Fail`] conflict), or
264274 /// - the resolved [`Uri`] cannot be converted back to an [`http::Uri`].
265275 pub fn resolve_request_uri ( & self , context : RouterContext , request : & mut HttpRequest ) -> Result < ( ) , HttpError > {
266- // Always route from the caller-supplied target so repeated calls
267- // are idempotent. Clone rather than take, so a failure below leaves
268- // the request unchanged.
269- let original: Uri = match request. extensions ( ) . get :: < RequestUris > ( ) {
270- Some ( uris) => uris. original ( ) . clone ( ) ,
271- None => request. uri ( ) . clone ( ) . try_into ( ) ?,
276+ // Route from the caller-supplied target so repeated calls are idempotent. A built
277+ // request carries its original target in `RequestUris`, so route from a single clone of
278+ // it and let `resolve_uri` consume that clone. A hand-built request has no such
279+ // extension, so derive the original from the request's URI and keep a copy to seed a
280+ // fresh `RequestUris` after routing; that extra clone is confined to the rarer branch.
281+ let ( original, fallback_original) : ( Uri , Option < Uri > ) = if let Some ( uris) = request. extensions ( ) . get :: < RequestUris > ( ) {
282+ ( uris. original ( ) . clone ( ) , None )
283+ } else {
284+ let original: Uri = request. uri ( ) . clone ( ) . try_into ( ) ?;
285+ ( original. clone ( ) , Some ( original) )
286+ } ;
287+ let resolved = self . resolve_uri ( context. with_request ( request) , original) ?;
288+
289+ // Routing only swaps the base, never the path, so reuse the path rendered once at
290+ // build time (cached as `RenderedPath`) and join just the resolved base onto it -
291+ // avoiding a second template render, including across retry/hedge attempts.
292+ //
293+ // Only trust `RenderedPath` when this is a built request (`RequestUris` was present,
294+ // i.e. `fallback_original` is `None`): then `resolved`'s path derives from the same
295+ // original the cache was rendered from, so they provably correspond. A hand-built
296+ // request routes from its own `http::Uri` and may carry an unrelated `RenderedPath`,
297+ // so it always full-materializes from `resolved` rather than trusting the cache.
298+ let http_uri = if fallback_original. is_none ( ) {
299+ match request. extensions ( ) . get :: < RenderedPath > ( ) {
300+ Some ( rendered) => resolved. to_http_uri_with_rendered ( Some ( & rendered. 0 ) ) ?,
301+ None => resolved. clone ( ) . try_into ( ) ?,
302+ }
303+ } else {
304+ resolved. clone ( ) . try_into ( ) ?
272305 } ;
273- let resolved = self . resolve_uri ( context. with_request ( request) , original. clone ( ) ) ?;
274- let http_uri = resolved. clone ( ) . try_into ( ) ?;
275306
276- // Commit: update the request's URI and record the resolved URI.
277- // Only `routed` is mutated; `original` is preserved across attempts.
307+ // Commit: update the request's URI and record the resolved URI. Only `routed` is
308+ // mutated; `original` is preserved across attempts.
278309 * request. uri_mut ( ) = http_uri;
279- request
280- . extensions_mut ( )
281- . get_or_insert_with ( || RequestUris :: new ( original) )
282- . set_routed ( resolved) ;
310+ if let Some ( original) = fallback_original {
311+ // Hand-built request: seed a fresh `RequestUris` from the preserved original.
312+ let mut uris = RequestUris :: new ( original) ;
313+ uris. set_routed ( resolved) ;
314+ request. extensions_mut ( ) . insert ( uris) ;
315+ } else if let Some ( uris) = request. extensions_mut ( ) . get_mut :: < RequestUris > ( ) {
316+ // Built request: the `RequestUris` extension already exists (it is why
317+ // `fallback_original` is `None`); just record the routed URI.
318+ uris. set_routed ( resolved) ;
319+ }
283320
284321 Ok ( ( ) )
285322 }
@@ -626,6 +663,28 @@ mod tests {
626663 ) ;
627664 }
628665
666+ #[ test]
667+ fn resolve_request_uri_ignores_stale_rendered_path_without_request_uris ( ) {
668+ // A hand-built request (no `RequestUris`) that happens to carry an unrelated
669+ // `RenderedPath` must NOT trust that cache: routing derives the target from the
670+ // request's own `http::Uri`, so it must full-materialize from there, never splicing in
671+ // the stale rendering. Guards the correct-by-construction reuse condition.
672+ let router = Router :: fixed ( BaseUri :: from_static ( "https://api.example.com" ) ) ;
673+ let body = crate :: HttpBodyBuilder :: new_fake ( ) . empty ( ) ;
674+ let mut request = http:: Request :: new ( body) ;
675+ * request. uri_mut ( ) = http:: Uri :: from_static ( "/v1/items" ) ;
676+ // Inject a stale rendering that does not correspond to the request's URI.
677+ request
678+ . extensions_mut ( )
679+ . insert ( RenderedPath ( http:: uri:: PathAndQuery :: from_static ( "/stale/wrong/path" ) ) ) ;
680+ assert ! ( request. extensions( ) . get:: <RequestUris >( ) . is_none( ) , "precondition: no RequestUris" ) ;
681+
682+ router. resolve_request_uri ( RouterContext :: new ( ) , & mut request) . unwrap ( ) ;
683+
684+ // The routed URI reflects the request's own path, not the stale cache.
685+ assert_eq ! ( request. uri( ) . to_string( ) , "https://api.example.com/v1/items" ) ;
686+ }
687+
629688 #[ test]
630689 fn resolve_request_uri_attaches_base_uri ( ) {
631690 let router = Router :: fixed ( BaseUri :: from_static ( "https://api.example.com" ) ) ;
@@ -636,6 +695,40 @@ mod tests {
636695 assert_eq ! ( request. uri( ) . to_string( ) , "https://api.example.com/v1/items" ) ;
637696 }
638697
698+ #[ test]
699+ fn resolve_request_uri_reuses_rendered_path_matching_full_render ( ) {
700+ // `resolve_request_uri` reuses the path rendered once at build time (the `RenderedPath`
701+ // extension) and joins only the resolved base. Its result must be byte-identical to the
702+ // pre-optimization behavior, which fully re-materialized the resolved `Uri`. This pins
703+ // the reuse path against the full-render reference across base-path joins, leading-slash
704+ // normalization, and query strings.
705+ let bases = [ "https://api.example.com" , "https://api.example.com/v1/" , "http://h:8080/deep/base/" ] ;
706+ let paths = [ "/items" , "/users/42?active=true" , "/a/b/c?x=1&y=2" , "/only?q=1" ] ;
707+
708+ for base_str in bases {
709+ for path_str in paths {
710+ let router = Router :: fixed ( BaseUri :: from_static ( base_str) ) ;
711+ let mut request = crate :: HttpRequestBuilder :: new_fake ( ) . get ( path_str) . build ( ) . unwrap ( ) ;
712+
713+ // Sanity: build attached a cached rendering that the reuse path will consume.
714+ assert ! (
715+ request. extensions( ) . get:: <RenderedPath >( ) . is_some( ) ,
716+ "build should cache a RenderedPath for {path_str:?}"
717+ ) ;
718+
719+ router. resolve_request_uri ( RouterContext :: new ( ) , & mut request) . unwrap ( ) ;
720+ let reused = request. uri ( ) . clone ( ) ;
721+
722+ // Reference: the old behavior - fully re-materialize the resolved `Uri`.
723+ let original: Uri = request. extensions ( ) . get :: < RequestUris > ( ) . unwrap ( ) . original ( ) . clone ( ) ;
724+ let resolved = router. resolve_uri ( RouterContext :: new ( ) , original) . unwrap ( ) ;
725+ let full_render: http:: Uri = resolved. try_into ( ) . unwrap ( ) ;
726+
727+ assert_eq ! ( reused, full_render, "mismatch for base {base_str:?} + path {path_str:?}" ) ;
728+ }
729+ }
730+ }
731+
639732 #[ test]
640733 fn resolve_request_uri_attaches_resolved_uri_extension ( ) {
641734 // After `resolve_request_uri`, the `RequestUris` extension's `routed`
0 commit comments