@@ -403,6 +403,138 @@ def test_defaults_body_to_typed_null_column(self, mock_lit, mock_udf):
403403 # With no body column, a typed null string column is applied instead.
404404 sentinel_udf .assert_called_once_with (null_col )
405405
406+ @patch ("pyspark.sql.functions.udf" )
407+ @patch ("pyspark.sql.functions.lit" )
408+ def test_per_row_path_is_appended_to_request_url (self , mock_lit , mock_udf ):
409+ from datacustomcode .named_credential .spark_default import (
410+ DefaultSparkNamedCredential ,
411+ )
412+
413+ sentinel_udf = MagicMock (name = "udf" )
414+ mock_udf .return_value = sentinel_udf
415+
416+ underlying = MagicMock ()
417+ underlying .request .return_value = HTTPResponse (
418+ status_code = 200 , headers = {}, body = "{}"
419+ )
420+ spark_nc = DefaultSparkNamedCredential (named_credential = underlying )
421+
422+ request = (
423+ HTTPRequestBuilder ()
424+ .set_url ("callout:gemini" )
425+ .set_method ("POST" )
426+ .set_headers ({"Accept" : "application/json" })
427+ .build ()
428+ )
429+ body_col = MagicMock (name = "body_col" )
430+ path_col = MagicMock (name = "path_col" )
431+ spark_nc .request_col (request , body_col , path = path_col )
432+
433+ # The UDF is applied over both the body and the per-row path columns.
434+ sentinel_udf .assert_called_once_with (body_col , path_col )
435+
436+ udf_fn = mock_udf .call_args .args [0 ]
437+ out = udf_fn ('{"q": 1}' , "/gemini-2.5-pro" )
438+
439+ assert out ["status" ] == "SUCCESS"
440+ sent_request , sent_body = underlying .request .call_args .args
441+ # The row's path is joined onto the template url; method/headers kept.
442+ assert sent_request .url == "callout:gemini/gemini-2.5-pro"
443+ assert sent_request .method == "POST"
444+ assert sent_request .headers == {"Accept" : "application/json" }
445+ assert sent_body == '{"q": 1}'
446+ # The caller's template request is left untouched.
447+ assert request .url == "callout:gemini"
448+
449+ @pytest .mark .parametrize (
450+ "base, path, expected" ,
451+ [
452+ ("callout:gemini" , "/v1/models" , "callout:gemini/v1/models" ),
453+ ("callout:gemini" , "?key=a" , "callout:gemini?key=a" ),
454+ (
455+ "callout:Geo/geocode" ,
456+ "?address=1+Main" ,
457+ "callout:Geo/geocode?address=1+Main" ,
458+ ),
459+ ("callout:Geo/v1" , "/places/42" , "callout:Geo/v1/places/42" ),
460+ # Without a leading separator a "/" is inserted, so the row can never
461+ # extend the Named Credential name (callout:gemini2/...).
462+ ("callout:gemini" , "2/v1" , "callout:gemini/2/v1" ),
463+ ("callout:gemini" , "@evil.example.com" , "callout:gemini/@evil.example.com" ),
464+ ],
465+ )
466+ @patch ("pyspark.sql.functions.udf" )
467+ @patch ("pyspark.sql.functions.lit" )
468+ def test_path_join_keeps_named_credential_fixed (
469+ self , mock_lit , mock_udf , base , path , expected
470+ ):
471+ from datacustomcode .named_credential .spark_default import (
472+ DefaultSparkNamedCredential ,
473+ )
474+
475+ mock_udf .return_value = MagicMock (name = "udf" )
476+ underlying = MagicMock ()
477+ underlying .request .return_value = HTTPResponse (
478+ status_code = 200 , headers = {}, body = ""
479+ )
480+ spark_nc = DefaultSparkNamedCredential (named_credential = underlying )
481+
482+ request = HTTPRequestBuilder ().set_url (base ).build ()
483+ spark_nc .request_col (request , path = MagicMock (name = "path_col" ))
484+
485+ udf_fn = mock_udf .call_args .args [0 ]
486+ udf_fn (None , path )
487+
488+ assert underlying .request .call_args .args [0 ].url == expected
489+
490+ @pytest .mark .parametrize ("path" , [None , "" ])
491+ @patch ("pyspark.sql.functions.udf" )
492+ @patch ("pyspark.sql.functions.lit" )
493+ def test_null_or_empty_row_path_uses_request_url (self , mock_lit , mock_udf , path ):
494+ from datacustomcode .named_credential .spark_default import (
495+ DefaultSparkNamedCredential ,
496+ )
497+
498+ mock_udf .return_value = MagicMock (name = "udf" )
499+
500+ underlying = MagicMock ()
501+ underlying .request .return_value = HTTPResponse (
502+ status_code = 200 , headers = {}, body = ""
503+ )
504+ spark_nc = DefaultSparkNamedCredential (named_credential = underlying )
505+
506+ request = HTTPRequestBuilder ().set_url ("callout:NC/default" ).build ()
507+ spark_nc .request_col (request , path = MagicMock (name = "path_col" ))
508+
509+ udf_fn = mock_udf .call_args .args [0 ]
510+ udf_fn ('{"a": 1}' , path )
511+
512+ sent_request , sent_body = underlying .request .call_args .args
513+ assert sent_request is request
514+ assert sent_body == '{"a": 1}'
515+
516+ @patch ("pyspark.sql.functions.udf" )
517+ @patch ("pyspark.sql.functions.lit" )
518+ def test_path_without_body_applies_typed_null_body (self , mock_lit , mock_udf ):
519+ from datacustomcode .named_credential .spark_default import (
520+ DefaultSparkNamedCredential ,
521+ )
522+
523+ null_col = MagicMock (name = "null_col" )
524+ lit_none = MagicMock (name = "lit_none" )
525+ lit_none .cast .return_value = null_col
526+ mock_lit .return_value = lit_none
527+
528+ sentinel_udf = MagicMock (name = "udf" )
529+ mock_udf .return_value = sentinel_udf
530+
531+ spark_nc = DefaultSparkNamedCredential (named_credential = MagicMock ())
532+ request = HTTPRequestBuilder ().set_url ("callout:NC/status" ).build ()
533+ path_col = MagicMock (name = "path_col" )
534+ spark_nc .request_col (request , path = path_col )
535+
536+ sentinel_udf .assert_called_once_with (null_col , path_col )
537+
406538
407539class TestInvokeCalloutAsStruct :
408540 """The callout-to-struct shaping shared by every row."""
0 commit comments