Skip to main content

apollo_federation/connectors/models/
http_json_transport.rs

1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::fmt::Write;
4use std::iter::once;
5use std::str::FromStr;
6
7use apollo_compiler::collections::IndexMap;
8use either::Either;
9use http::Uri;
10use http::uri::InvalidUri;
11use http::uri::InvalidUriParts;
12use http::uri::Parts;
13use http::uri::PathAndQuery;
14use serde_json_bytes::Value;
15use serde_json_bytes::json;
16use thiserror::Error;
17
18use super::ProblemLocation;
19use crate::connectors::ApplyToError;
20use crate::connectors::ConnectSpec;
21use crate::connectors::JSONSelection;
22use crate::connectors::Namespace;
23use crate::connectors::PathSelection;
24use crate::connectors::StringTemplate;
25use crate::connectors::json_selection::VarPaths;
26use crate::connectors::models::Header;
27use crate::connectors::spec::ConnectHTTPArguments;
28use crate::connectors::spec::SourceHTTPArguments;
29use crate::connectors::string_template;
30use crate::connectors::string_template::UriString;
31use crate::connectors::string_template::write_value;
32use crate::connectors::variable::VariableReference;
33use crate::error::FederationError;
34
35#[derive(Clone, Debug, Default)]
36pub struct HttpJsonTransport {
37    pub source_template: Option<StringTemplate>,
38    pub connect_template: StringTemplate,
39    pub method: HTTPMethod,
40    pub headers: Vec<Header>,
41    pub body: Option<JSONSelection>,
42    pub source_path: Option<JSONSelection>,
43    pub source_query_params: Option<JSONSelection>,
44    pub connect_path: Option<JSONSelection>,
45    pub connect_query_params: Option<JSONSelection>,
46}
47
48impl HttpJsonTransport {
49    pub fn from_directive(
50        http: ConnectHTTPArguments,
51        source: Option<&SourceHTTPArguments>,
52        spec: ConnectSpec,
53    ) -> Result<Self, FederationError> {
54        let (method, connect_url) = if let Some(url) = &http.get {
55            (HTTPMethod::Get, url)
56        } else if let Some(url) = &http.post {
57            (HTTPMethod::Post, url)
58        } else if let Some(url) = &http.patch {
59            (HTTPMethod::Patch, url)
60        } else if let Some(url) = &http.put {
61            (HTTPMethod::Put, url)
62        } else if let Some(url) = &http.delete {
63            (HTTPMethod::Delete, url)
64        } else {
65            return Err(FederationError::internal("missing http method"));
66        };
67
68        let mut headers = http.headers;
69        for header in source.map(|source| &source.headers).into_iter().flatten() {
70            if !headers
71                .iter()
72                .any(|connect_header| connect_header.name == header.name)
73            {
74                headers.push(header.clone());
75            }
76        }
77
78        Ok(Self {
79            source_template: source.map(|source| source.base_url.template.clone()),
80            connect_template: StringTemplate::parse_with_spec(connect_url, spec).map_err(
81                |e: string_template::Error| {
82                    FederationError::internal(format!(
83                        "could not parse URL template: {message}",
84                        message = e.message
85                    ))
86                },
87            )?,
88            method,
89            headers,
90            body: http.body,
91            source_path: source.and_then(|s| s.path.clone()),
92            source_query_params: source.and_then(|s| s.query_params.clone()),
93            connect_path: http.path,
94            connect_query_params: http.query_params,
95        })
96    }
97
98    pub(super) fn label(&self) -> String {
99        format!("http: {} {}", self.method, self.connect_template)
100    }
101
102    pub(crate) fn variable_references(&self) -> impl Iterator<Item = VariableReference<Namespace>> {
103        let url_selections = self.connect_template.expressions().map(|e| &e.expression);
104        let header_selections = self
105            .headers
106            .iter()
107            .flat_map(|header| header.source.expressions());
108
109        let source_selections = self
110            .source_template
111            .iter()
112            .flat_map(|template| template.expressions().map(|e| &e.expression));
113
114        url_selections
115            .chain(header_selections)
116            .chain(source_selections)
117            .chain(self.body.iter())
118            .chain(self.source_path.iter())
119            .chain(self.source_query_params.iter())
120            .chain(self.connect_path.iter())
121            .chain(self.connect_query_params.iter())
122            .flat_map(|b| {
123                b.external_var_paths()
124                    .into_iter()
125                    .flat_map(PathSelection::variable_reference)
126            })
127    }
128
129    pub fn make_uri(
130        &self,
131        inputs: &IndexMap<String, Value>,
132    ) -> Result<(Uri, Vec<(ProblemLocation, ApplyToError)>), MakeUriError> {
133        let mut uri_parts = Parts::default();
134        let mut warnings = Vec::new();
135
136        let (connect_uri, connect_template_warnings) =
137            self.connect_template.interpolate_uri(inputs)?;
138        warnings.extend(
139            connect_template_warnings
140                .into_iter()
141                .map(|warning| (ProblemLocation::ConnectUrl, warning)),
142        );
143        let resolved_source_uri = match &self.source_template {
144            Some(template) => {
145                let (uri, source_template_warnings) = template.interpolate_uri(inputs)?;
146                warnings.extend(
147                    source_template_warnings
148                        .into_iter()
149                        .map(|warning| (ProblemLocation::SourceUrl, warning)),
150                );
151                Some(uri)
152            }
153            None => None,
154        };
155
156        if let Some(source_uri) = &resolved_source_uri {
157            uri_parts.scheme = source_uri.scheme().cloned();
158            uri_parts.authority = source_uri.authority().cloned();
159        } else {
160            uri_parts.scheme = connect_uri.scheme().cloned();
161            uri_parts.authority = connect_uri.authority().cloned();
162        }
163
164        let mut path = UriString::new();
165        if let Some(source_uri) = &resolved_source_uri {
166            path.write_without_encoding(source_uri.path())?;
167        }
168        if let Some(source_path) = self.source_path.as_ref() {
169            warnings.extend(
170                extend_path_from_expression(&mut path, source_path, inputs)?
171                    .into_iter()
172                    .map(|error| (ProblemLocation::SourcePath, error)),
173            );
174        }
175        let connect_path = connect_uri.path();
176        if !connect_path.is_empty() && connect_path != "/" {
177            if path.ends_with('/') {
178                path.write_without_encoding(connect_path.trim_start_matches('/'))?;
179            } else if connect_path.starts_with('/') {
180                path.write_without_encoding(connect_path)?;
181            } else {
182                path.write_without_encoding("/")?;
183                path.write_without_encoding(connect_path)?;
184            };
185        }
186        if let Some(connect_path) = self.connect_path.as_ref() {
187            warnings.extend(
188                extend_path_from_expression(&mut path, connect_path, inputs)?
189                    .into_iter()
190                    .map(|error| (ProblemLocation::ConnectPath, error)),
191            );
192        }
193
194        let mut query = UriString::new();
195        if let Some(source_uri_query) = resolved_source_uri
196            .as_ref()
197            .and_then(|source_uri| source_uri.query())
198        {
199            query.write_without_encoding(source_uri_query)?;
200        }
201        if let Some(source_query) = self.source_query_params.as_ref() {
202            warnings.extend(
203                extend_query_from_expression(&mut query, source_query, inputs)?
204                    .into_iter()
205                    .map(|error| (ProblemLocation::SourceQueryParams, error)),
206            );
207        }
208        let connect_query = connect_uri.query().unwrap_or_default();
209        if !connect_query.is_empty() {
210            if !query.is_empty() && !query.ends_with('&') {
211                query.write_without_encoding("&")?;
212            }
213            query.write_without_encoding(connect_query)?;
214        }
215        if let Some(connect_query) = self.connect_query_params.as_ref() {
216            warnings.extend(
217                extend_query_from_expression(&mut query, connect_query, inputs)?
218                    .into_iter()
219                    .map(|error| (ProblemLocation::ConnectQueryParams, error)),
220            );
221        }
222
223        let path = path.into_string();
224        let query = query.into_string();
225
226        uri_parts.path_and_query = Some(match (path.is_empty(), query.is_empty()) {
227            (true, true) => PathAndQuery::from_static(""),
228            (true, false) => PathAndQuery::try_from(format!("?{query}"))?,
229            (false, true) => PathAndQuery::try_from(path)?,
230            (false, false) => PathAndQuery::try_from(format!("{path}?{query}"))?,
231        });
232
233        let uri = Uri::from_parts(uri_parts).map_err(MakeUriError::BuildMergedUri)?;
234
235        Ok((uri, warnings))
236    }
237}
238
239/// Path segments can optionally be appended from the `http.path` inputs, each of which are a
240/// [`JSONSelection`] expression expected to evaluate to an array.
241fn extend_path_from_expression(
242    path: &mut UriString,
243    expression: &JSONSelection,
244    inputs: &IndexMap<String, Value>,
245) -> Result<Vec<ApplyToError>, MakeUriError> {
246    let (value, warnings) = expression.apply_with_vars(&json!({}), inputs);
247    let Some(value) = value else {
248        return Ok(warnings);
249    };
250    let Value::Array(values) = value else {
251        return Err(MakeUriError::PathComponents(
252            "Expression did not evaluate to an array".into(),
253        ));
254    };
255    for value in &values {
256        if !path.ends_with('/') {
257            path.write_trusted("/")?;
258        }
259        write_value(&mut *path, value)
260            .map_err(|err| MakeUriError::PathComponents(err.to_string()))?;
261    }
262    Ok(warnings)
263}
264
265fn extend_query_from_expression(
266    query: &mut UriString,
267    expression: &JSONSelection,
268    inputs: &IndexMap<String, Value>,
269) -> Result<Vec<ApplyToError>, MakeUriError> {
270    let (value, warnings) = expression.apply_with_vars(&json!({}), inputs);
271    let Some(value) = value else {
272        return Ok(warnings);
273    };
274    let Value::Object(map) = value else {
275        return Err(MakeUriError::QueryParams(
276            "Expression did not evaluate to an object".into(),
277        ));
278    };
279
280    let all_params = map
281        .iter()
282        .filter(|(_, value)| !value.is_null())
283        .flat_map(|(key, value)| {
284            if let Value::Array(values) = value {
285                // If the top-level value is an array, we're going to turn that into repeated params
286                Either::Left(values.iter().map(|value| (key.as_str(), value)))
287            } else {
288                Either::Right(once((key.as_str(), value)))
289            }
290        });
291
292    for (key, value) in all_params {
293        if !query.is_empty() && !query.ends_with('&') {
294            query.write_trusted("&")?;
295        }
296        query.write_str(key)?;
297        query.write_trusted("=")?;
298        write_value(&mut *query, value)
299            .map_err(|err| MakeUriError::QueryParams(err.to_string()))?;
300    }
301    Ok(warnings)
302}
303
304#[derive(Debug, Error)]
305pub enum MakeUriError {
306    #[error("Error building URI: {0}")]
307    ParsePathAndQuery(#[from] InvalidUri),
308    #[error("Error building URI: {0}")]
309    BuildMergedUri(InvalidUriParts),
310    #[error("Error rendering URI template: {0}")]
311    TemplateGenerationError(#[from] string_template::Error),
312    #[error("Internal error building URI")]
313    WriteError(#[from] std::fmt::Error),
314    #[error("Error building path components from expression: {0}")]
315    PathComponents(String),
316    #[error("Error building query parameters from queryParams: {0}")]
317    QueryParams(String),
318}
319
320/// The HTTP arguments needed for a connect request
321#[derive(Debug, Clone, Copy, Default)]
322pub enum HTTPMethod {
323    #[default]
324    Get,
325    Post,
326    Patch,
327    Put,
328    Delete,
329}
330
331impl HTTPMethod {
332    #[inline]
333    pub const fn as_str(&self) -> &str {
334        match self {
335            HTTPMethod::Get => "GET",
336            HTTPMethod::Post => "POST",
337            HTTPMethod::Patch => "PATCH",
338            HTTPMethod::Put => "PUT",
339            HTTPMethod::Delete => "DELETE",
340        }
341    }
342}
343
344impl FromStr for HTTPMethod {
345    type Err = String;
346
347    fn from_str(s: &str) -> Result<Self, Self::Err> {
348        match s.to_uppercase().as_str() {
349            "GET" => Ok(HTTPMethod::Get),
350            "POST" => Ok(HTTPMethod::Post),
351            "PATCH" => Ok(HTTPMethod::Patch),
352            "PUT" => Ok(HTTPMethod::Put),
353            "DELETE" => Ok(HTTPMethod::Delete),
354            _ => Err(format!("Invalid HTTP method: {s}")),
355        }
356    }
357}
358
359impl Display for HTTPMethod {
360    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
361        write!(f, "{}", self.as_str())
362    }
363}
364
365#[cfg(test)]
366mod test_make_uri {
367    use std::str::FromStr;
368
369    use apollo_compiler::collections::IndexMap;
370    use pretty_assertions::assert_eq;
371    use serde_json_bytes::json;
372
373    use super::*;
374    use crate::connectors::JSONSelection;
375
376    /// Take data from all the places it can come from and make sure they combine in the right order
377    #[test]
378    fn merge_all_sources() {
379        let transport = HttpJsonTransport {
380            source_template: StringTemplate::from_str(
381                "http://example.com/sourceUri?shared=sourceUri&sourceUri=sourceUri",
382            )
383            .ok(),
384            connect_template: StringTemplate::parse_with_spec(
385                "/{$args.connectUri}?shared={$args.connectUri}&{$args.connectUri}={$args.connectUri}",
386                ConnectSpec::latest(),
387            )
388            .unwrap(),
389            source_path: JSONSelection::parse("$args.sourcePath").ok(),
390            connect_path: JSONSelection::parse("$args.connectPath").ok(),
391            source_query_params: JSONSelection::parse("$args.sourceQuery").ok(),
392            connect_query_params: JSONSelection::parse("$args.connectQuery").ok(),
393            ..Default::default()
394        };
395        let inputs = IndexMap::from_iter([(
396            "$args".to_string(),
397            json!({
398                "connectUri": "connectUri",
399                "sourcePath": ["sourcePath1", "sourcePath2"],
400                "connectPath": ["connectPath1", "connectPath2"],
401                "sourceQuery": {"shared": "sourceQuery", "sourceQuery": "sourceQuery"},
402                "connectQuery": {"shared": "connectQuery", "connectQuery": "connectQuery"},
403            }),
404        )]);
405        let (url, _) = transport.make_uri(&inputs).unwrap();
406        assert_eq!(
407            url.to_string(),
408            "http://example.com/sourceUri/sourcePath1/sourcePath2/connectUri/connectPath1/connectPath2\
409            ?shared=sourceUri&sourceUri=sourceUri\
410            &shared=sourceQuery&sourceQuery=sourceQuery\
411            &shared=connectUri&connectUri=connectUri\
412            &shared=connectQuery&connectQuery=connectQuery"
413        );
414    }
415
416    macro_rules! this {
417        ($($value:tt)*) => {{
418            let mut map = IndexMap::with_capacity_and_hasher(1, Default::default());
419            map.insert("$this".to_string(), serde_json_bytes::json!({ $($value)* }));
420            map
421        }};
422    }
423
424    mod combining_paths {
425        use pretty_assertions::assert_eq;
426        use rstest::rstest;
427
428        use super::*;
429
430        #[rstest]
431        #[case::connect_only("https://localhost:8080/v1", "/hello")]
432        #[case::source_only("https://localhost:8080/v1/", "hello")]
433        #[case::neither("https://localhost:8080/v1", "hello")]
434        #[case::both("https://localhost:8080/v1/", "/hello")]
435        fn slashes_between_source_and_connect(
436            #[case] source_uri: &str,
437            #[case] connect_path: &str,
438        ) {
439            let transport = HttpJsonTransport {
440                source_template: StringTemplate::from_str(source_uri).ok(),
441                connect_template: connect_path.parse().unwrap(),
442                ..Default::default()
443            };
444            assert_eq!(
445                transport
446                    .make_uri(&Default::default())
447                    .unwrap()
448                    .0
449                    .to_string(),
450                "https://localhost:8080/v1/hello"
451            );
452        }
453
454        #[rstest]
455        #[case::when_base_has_trailing("http://localhost/")]
456        #[case::when_base_does_not_have_trailing("http://localhost")]
457        fn handle_slashes_when_adding_path_expression(#[case] base: &str) {
458            let transport = HttpJsonTransport {
459                source_template: StringTemplate::from_str(base).ok(),
460                source_path: JSONSelection::parse("$([1, 2])").ok(),
461                ..Default::default()
462            };
463            assert_eq!(
464                transport
465                    .make_uri(&Default::default())
466                    .unwrap()
467                    .0
468                    .to_string(),
469                "http://localhost/1/2"
470            );
471        }
472
473        #[test]
474        fn preserve_trailing_slash_from_connect() {
475            let transport = HttpJsonTransport {
476                source_template: StringTemplate::from_str("https://localhost:8080/v1").ok(),
477                connect_template: "/hello/".parse().unwrap(),
478                ..Default::default()
479            };
480            assert_eq!(
481                transport
482                    .make_uri(&Default::default())
483                    .unwrap()
484                    .0
485                    .to_string(),
486                "https://localhost:8080/v1/hello/"
487            );
488        }
489
490        #[test]
491        fn preserve_trailing_slash_from_source() {
492            let transport = HttpJsonTransport {
493                source_template: StringTemplate::from_str("https://localhost:8080/v1/").ok(),
494                connect_template: "/".parse().unwrap(),
495                ..Default::default()
496            };
497            assert_eq!(
498                transport
499                    .make_uri(&Default::default())
500                    .unwrap()
501                    .0
502                    .to_string(),
503                "https://localhost:8080/v1/"
504            );
505        }
506
507        #[test]
508        fn preserve_no_trailing_slash_from_source() {
509            let transport = HttpJsonTransport {
510                source_template: StringTemplate::from_str("https://localhost:8080/v1").ok(),
511                connect_template: "/".parse().unwrap(),
512                ..Default::default()
513            };
514            assert_eq!(
515                transport
516                    .make_uri(&Default::default())
517                    .unwrap()
518                    .0
519                    .to_string(),
520                "https://localhost:8080/v1"
521            );
522        }
523
524        #[test]
525        fn add_path_before_query_params() {
526            let transport = HttpJsonTransport {
527                source_template: StringTemplate::from_str("https://localhost:8080/v1?something")
528                    .ok(),
529                connect_template: "/hello".parse().unwrap(),
530                ..Default::default()
531            };
532            assert_eq!(
533                transport
534                    .make_uri(&this! { "id": 42 })
535                    .unwrap()
536                    .0
537                    .to_string(),
538                "https://localhost:8080/v1/hello?something"
539            );
540        }
541
542        #[test]
543        fn trailing_slash_plus_query_params() {
544            let transport = HttpJsonTransport {
545                source_template: StringTemplate::from_str("https://localhost:8080/v1/?something")
546                    .ok(),
547                connect_template: "/hello/".parse().unwrap(),
548                ..Default::default()
549            };
550            assert_eq!(
551                transport
552                    .make_uri(&this! { "id": 42 })
553                    .unwrap()
554                    .0
555                    .to_string(),
556                "https://localhost:8080/v1/hello/?something"
557            );
558        }
559
560        #[test]
561        fn with_trailing_slash_in_base_plus_query_params() {
562            let transport = HttpJsonTransport {
563                source_template: StringTemplate::from_str("https://localhost:8080/v1/?foo=bar")
564                    .ok(),
565                connect_template: StringTemplate::parse_with_spec(
566                    "/hello/{$this.id}?id={$this.id}",
567                    ConnectSpec::latest(),
568                )
569                .unwrap(),
570                ..Default::default()
571            };
572            assert_eq!(
573                transport
574                    .make_uri(&this! {"id": 42 })
575                    .unwrap()
576                    .0
577                    .to_string(),
578                "https://localhost:8080/v1/hello/42?foo=bar&id=42"
579            );
580        }
581    }
582
583    mod merge_query {
584        use pretty_assertions::assert_eq;
585
586        use super::*;
587        #[test]
588        fn source_only() {
589            let transport = HttpJsonTransport {
590                source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(),
591                connect_template: "/123".parse().unwrap(),
592                ..Default::default()
593            };
594            assert_eq!(
595                transport.make_uri(&Default::default()).unwrap().0,
596                "http://localhost/users/123?a=b"
597            );
598        }
599
600        #[test]
601        fn connect_only() {
602            let transport = HttpJsonTransport {
603                source_template: StringTemplate::from_str("http://localhost/users").ok(),
604                connect_template: "?a=b&c=d".parse().unwrap(),
605                ..Default::default()
606            };
607            assert_eq!(
608                transport.make_uri(&Default::default()).unwrap().0,
609                "http://localhost/users?a=b&c=d"
610            )
611        }
612
613        #[test]
614        fn combine_from_both_uris() {
615            let transport = HttpJsonTransport {
616                source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(),
617                connect_template: "?c=d".parse().unwrap(),
618                ..Default::default()
619            };
620            assert_eq!(
621                transport.make_uri(&Default::default()).unwrap().0,
622                "http://localhost/users?a=b&c=d"
623            )
624        }
625
626        #[test]
627        fn source_and_connect_have_same_param() {
628            let transport = HttpJsonTransport {
629                source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(),
630                connect_template: "?a=d".parse().unwrap(),
631                ..Default::default()
632            };
633            assert_eq!(
634                transport.make_uri(&Default::default()).unwrap().0,
635                "http://localhost/users?a=b&a=d"
636            )
637        }
638
639        #[test]
640        fn repeated_params_from_array() {
641            let transport = HttpJsonTransport {
642                connect_template: "http://localhost".parse().unwrap(),
643                connect_query_params: JSONSelection::parse("$args.connectQuery").ok(),
644                ..Default::default()
645            };
646            let inputs = IndexMap::from_iter([(
647                "$args".to_string(),
648                json!({
649                    "connectQuery": {"multi": ["first", "second"]},
650                }),
651            )]);
652            assert_eq!(
653                transport.make_uri(&inputs).unwrap().0,
654                "http://localhost?multi=first&multi=second"
655            )
656        }
657    }
658
659    #[test]
660    fn fragments_are_dropped() {
661        let transport = HttpJsonTransport {
662            source_template: StringTemplate::from_str("http://localhost/source?a=b#SourceFragment")
663                .ok(),
664            connect_template: "/connect?c=d#connectFragment".parse().unwrap(),
665            ..Default::default()
666        };
667        assert_eq!(
668            transport.make_uri(&Default::default()).unwrap().0,
669            "http://localhost/source/connect?a=b&c=d"
670        )
671    }
672
673    /// When merging source and connect pieces, we sometimes have to apply encoding as we go.
674    /// This double-checks that we never _double_ encode pieces.
675    #[test]
676    fn pieces_are_not_double_encoded() {
677        let transport = HttpJsonTransport {
678            source_template: StringTemplate::from_str(
679                "http://localhost/source%20path?param=source%20param",
680            )
681            .ok(),
682            connect_template: "/connect%20path?param=connect%20param".parse().unwrap(),
683            ..Default::default()
684        };
685        assert_eq!(
686            transport.make_uri(&Default::default()).unwrap().0,
687            "http://localhost/source%20path/connect%20path?param=source%20param&param=connect%20param"
688        )
689    }
690
691    /// Regression test for a very specific case where the resulting `Uri` might not be valid
692    /// because we did _too little_ work.
693    #[test]
694    fn empty_path_and_query() {
695        let transport = HttpJsonTransport {
696            source_template: None,
697            connect_template: "http://localhost/".parse().unwrap(),
698            ..Default::default()
699        };
700        assert_eq!(
701            transport.make_uri(&Default::default()).unwrap().0,
702            "http://localhost/"
703        )
704    }
705
706    #[test]
707    fn skip_null_query_params() {
708        let transport = HttpJsonTransport {
709            source_template: None,
710            connect_template: "http://localhost/".parse().unwrap(),
711            connect_query_params: JSONSelection::parse("something: $(null)").ok(),
712            ..Default::default()
713        };
714
715        assert_eq!(
716            transport.make_uri(&Default::default()).unwrap().0,
717            "http://localhost/"
718        )
719    }
720
721    #[test]
722    fn skip_null_path_params() {
723        let transport = HttpJsonTransport {
724            source_template: None,
725            connect_template: "http://localhost/".parse().unwrap(),
726            connect_path: JSONSelection::parse("$([1, null, 2])").ok(),
727            ..Default::default()
728        };
729
730        assert_eq!(
731            transport.make_uri(&Default::default()).unwrap().0,
732            "http://localhost/1/2"
733        )
734    }
735
736    #[test]
737    fn source_template_variables_retained() {
738        let transport = HttpJsonTransport {
739            source_template: StringTemplate::parse_with_spec(
740                "http://${$config.subdomain}.localhost",
741                ConnectSpec::latest(),
742            )
743            .ok(),
744            connect_template: "/connect?c=d".parse().unwrap(),
745            ..Default::default()
746        };
747
748        // Transport variables contain the config reference
749        transport
750            .variable_references()
751            .find(|var_ref| var_ref.namespace.namespace == Namespace::Config)
752            .unwrap();
753    }
754
755    #[test]
756    fn source_template_interpolated_correctly() {
757        let transport = HttpJsonTransport {
758            source_template: StringTemplate::parse_with_spec(
759                "http://{$config.subdomain}.localhost:{$config.port}",
760                ConnectSpec::latest(),
761            )
762            .ok(),
763            connect_template: "/connect?c=d".parse().unwrap(),
764            ..Default::default()
765        };
766        let mut vars: IndexMap<String, Value> = Default::default();
767        vars.insert(
768            "$config".to_string(),
769            json!({ "subdomain": "api", "port": 5000 }),
770        );
771        assert_eq!(
772            transport.make_uri(&vars).unwrap().0,
773            "http://api.localhost:5000/connect?c=d"
774        );
775    }
776}