1818//! Responsibilities include multipart file uploads, response pagination,
1919//! error mapping, and optionally running text content through Model Armor for sanitization.
2020
21- use std:: collections:: HashMap ;
21+ use std:: collections:: { HashMap , HashSet } ;
2222use std:: path:: PathBuf ;
2323
2424use anyhow:: Context ;
@@ -513,7 +513,7 @@ fn build_url(
513513 } ;
514514
515515 // Substitute path parameters and separate query parameters
516- let mut url_path = path_template . to_string ( ) ;
516+ let path_parameters = extract_template_path_parameters ( path_template ) ;
517517 let mut query_params: HashMap < String , String > = HashMap :: new ( ) ;
518518
519519 for ( key, value) in params {
@@ -522,29 +522,7 @@ fn build_url(
522522 other => other. to_string ( ) ,
523523 } ;
524524
525- let placeholder = format ! ( "{{{key}}}" ) ;
526- // Also handle {+key} style
527- let plus_placeholder = format ! ( "{{+{key}}}" ) ;
528- let has_plain_placeholder = url_path. contains ( & placeholder) ;
529- let has_plus_placeholder = url_path. contains ( & plus_placeholder) ;
530-
531- if has_plain_placeholder || has_plus_placeholder {
532- // RFC 6570-like expansion:
533- // - {var}: path segment expansion (encode all reserved characters)
534- // - {+var}: reserved expansion (preserve '/' path separators only)
535- if has_plain_placeholder {
536- // TODO: This iterative `replace` can expand placeholder-looking
537- // substrings that appear inside another parameter's value.
538- // A single-pass template renderer would avoid that class of bugs.
539- let encoded = crate :: validate:: encode_path_segment ( & val_str) ;
540- url_path = url_path. replace ( & placeholder, & encoded) ;
541- } else if has_plus_placeholder {
542- // `{+var}` keeps `/` separators, so validate before encoding to
543- // block traversal and URL-special injection payloads.
544- let validated = crate :: validate:: validate_resource_name ( & val_str) ?;
545- let encoded = crate :: validate:: encode_path_preserving_slashes ( validated) ;
546- url_path = url_path. replace ( & plus_placeholder, & encoded) ;
547- }
525+ if path_parameters. contains ( key. as_str ( ) ) {
548526 continue ;
549527 }
550528
@@ -565,6 +543,8 @@ fn build_url(
565543 query_params. insert ( key. clone ( ) , val_str) ;
566544 }
567545
546+ let url_path = render_path_template ( path_template, params) ?;
547+
568548 let full_url = if is_upload {
569549 // Use the upload endpoint from the Discovery Document
570550 let upload_endpoint = method
@@ -579,14 +559,85 @@ fn build_url(
579559 . to_string ( ) ,
580560 )
581561 } ) ?;
582- format ! ( "{}{}" , doc. root_url. trim_end_matches( '/' ) , upload_endpoint)
562+ let upload_path = render_path_template ( upload_endpoint, params) ?;
563+ format ! ( "{}{}" , doc. root_url. trim_end_matches( '/' ) , upload_path)
583564 } else {
584565 format ! ( "{base_url}{url_path}" )
585566 } ;
586567
587568 Ok ( ( full_url, query_params) )
588569}
589570
571+ fn extract_template_path_parameters ( path_template : & str ) -> HashSet < & str > {
572+ let mut found = HashSet :: new ( ) ;
573+ let mut cursor = 0 ;
574+
575+ while let Some ( open_idx) = path_template[ cursor..] . find ( '{' ) {
576+ let token_start = cursor + open_idx;
577+ let Some ( close_idx) = path_template[ token_start..] . find ( '}' ) else {
578+ break ;
579+ } ;
580+
581+ let token_end = token_start + close_idx;
582+ let token = & path_template[ token_start + 1 ..token_end] ;
583+ if let Some ( key) = token. strip_prefix ( '+' ) {
584+ found. insert ( key) ;
585+ } else {
586+ found. insert ( token) ;
587+ }
588+ cursor = token_end + 1 ;
589+ }
590+
591+ found
592+ }
593+
594+ fn render_path_template (
595+ path_template : & str ,
596+ params : & Map < String , Value > ,
597+ ) -> Result < String , GwsError > {
598+ let mut rendered = String :: with_capacity ( path_template. len ( ) ) ;
599+ let mut cursor = 0 ;
600+
601+ while let Some ( open_idx) = path_template[ cursor..] . find ( '{' ) {
602+ let token_start = cursor + open_idx;
603+ rendered. push_str ( & path_template[ cursor..token_start] ) ;
604+
605+ let Some ( close_idx) = path_template[ token_start..] . find ( '}' ) else {
606+ rendered. push_str ( & path_template[ token_start..] ) ;
607+ return Ok ( rendered) ;
608+ } ;
609+
610+ let token_end = token_start + close_idx;
611+ let token = & path_template[ token_start + 1 ..token_end] ;
612+ let ( is_plus, key) = if let Some ( key) = token. strip_prefix ( '+' ) {
613+ ( true , key)
614+ } else {
615+ ( false , token)
616+ } ;
617+
618+ if let Some ( value) = params. get ( key) {
619+ let val_str = match value {
620+ Value :: String ( s) => s. clone ( ) ,
621+ other => other. to_string ( ) ,
622+ } ;
623+ let encoded = if is_plus {
624+ let validated = crate :: validate:: validate_resource_name ( & val_str) ?;
625+ crate :: validate:: encode_path_preserving_slashes ( validated)
626+ } else {
627+ crate :: validate:: encode_path_segment ( & val_str)
628+ } ;
629+ rendered. push_str ( & encoded) ;
630+ } else {
631+ rendered. push_str ( & path_template[ token_start..=token_end] ) ;
632+ }
633+
634+ cursor = token_end + 1 ;
635+ }
636+
637+ rendered. push_str ( & path_template[ cursor..] ) ;
638+ Ok ( rendered)
639+ }
640+
590641/// Attempts to extract a GCP console enable URL from a Google API `accessNotConfigured`
591642/// error message.
592643///
@@ -1339,6 +1390,68 @@ mod tests {
13391390 assert ! ( err. to_string( ) . contains( "path traversal" ) ) ;
13401391 }
13411392
1393+ #[ test]
1394+ fn test_build_url_upload_endpoint_substitutes_path_params ( ) {
1395+ let doc = RestDescription {
1396+ root_url : "https://www.googleapis.com/" . to_string ( ) ,
1397+ ..Default :: default ( )
1398+ } ;
1399+ let mut parameters = HashMap :: new ( ) ;
1400+ parameters. insert (
1401+ "fileId" . to_string ( ) ,
1402+ crate :: discovery:: MethodParameter {
1403+ location : Some ( "path" . to_string ( ) ) ,
1404+ ..Default :: default ( )
1405+ } ,
1406+ ) ;
1407+ let method = RestMethod {
1408+ path : "drive/v3/files/{fileId}" . to_string ( ) ,
1409+ flat_path : Some ( "drive/v3/files/{fileId}" . to_string ( ) ) ,
1410+ parameters,
1411+ media_upload : Some ( crate :: discovery:: MediaUpload {
1412+ protocols : Some ( crate :: discovery:: MediaUploadProtocols {
1413+ simple : Some ( crate :: discovery:: MediaUploadProtocol {
1414+ path : "/upload/drive/v3/files/{fileId}" . to_string ( ) ,
1415+ multipart : Some ( true ) ,
1416+ } ) ,
1417+ } ) ,
1418+ ..Default :: default ( )
1419+ } ) ,
1420+ ..Default :: default ( )
1421+ } ;
1422+
1423+ let mut params = Map :: new ( ) ;
1424+ params. insert ( "fileId" . to_string ( ) , json ! ( "abc/123" ) ) ;
1425+
1426+ let ( url, _) = build_url ( & doc, & method, & params, true ) . unwrap ( ) ;
1427+ assert_eq ! (
1428+ url,
1429+ "https://www.googleapis.com/upload/drive/v3/files/abc%2F123"
1430+ ) ;
1431+ }
1432+
1433+ #[ test]
1434+ fn test_build_url_does_not_replace_placeholder_like_values ( ) {
1435+ let doc = RestDescription {
1436+ base_url : Some ( "https://api.example.com/" . to_string ( ) ) ,
1437+ ..Default :: default ( )
1438+ } ;
1439+ let method = RestMethod {
1440+ path : "v1/{parent}/{child}" . to_string ( ) ,
1441+ flat_path : Some ( "v1/{parent}/{child}" . to_string ( ) ) ,
1442+ ..Default :: default ( )
1443+ } ;
1444+ let mut params = Map :: new ( ) ;
1445+ params. insert ( "parent" . to_string ( ) , json ! ( "literal-{child}-value" ) ) ;
1446+ params. insert ( "child" . to_string ( ) , json ! ( "ok" ) ) ;
1447+
1448+ let ( url, _) = build_url ( & doc, & method, & params, false ) . unwrap ( ) ;
1449+ assert_eq ! (
1450+ url,
1451+ "https://api.example.com/v1/literal%2D%7Bchild%7D%2Dvalue/ok"
1452+ ) ;
1453+ }
1454+
13421455 #[ test]
13431456 fn test_build_url_errors_for_path_param_not_in_template ( ) {
13441457 let doc = RestDescription {
0 commit comments