diff --git a/Cargo.lock b/Cargo.lock index fc44dc1209..43e98f7aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,7 @@ dependencies = [ "petgraph", "pretty_assertions", "ron", + "rstest", "serde", "serde_json", "serde_json_bytes", @@ -931,7 +932,7 @@ dependencies = [ "Inflector", "async-graphql-parser", "darling", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -2477,7 +2478,7 @@ dependencies = [ "lazy-regex", "once_cell", "pmutil", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "regex", @@ -5539,6 +5540,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit 0.21.1", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -5919,6 +5929,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.11.27" @@ -6192,6 +6208,36 @@ dependencies = [ "log", ] +[[package]] +name = "rstest" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version 0.4.0", +] + +[[package]] +name = "rstest_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +dependencies = [ + "cfg-if 1.0.0", + "glob", + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version 0.4.0", + "syn 2.0.71", + "unicode-ident", +] + [[package]] name = "rust-embed" version = "8.5.0" @@ -7401,6 +7447,17 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.16" diff --git a/apollo-federation/Cargo.toml b/apollo-federation/Cargo.toml index 4040117227..1a681022cb 100644 --- a/apollo-federation/Cargo.toml +++ b/apollo-federation/Cargo.toml @@ -47,6 +47,7 @@ insta.workspace = true sha1.workspace = true tempfile.workspace = true pretty_assertions = "1.4.0" +rstest = "0.22.0" [[test]] name = "main" diff --git a/apollo-federation/src/sources/connect/header.rs b/apollo-federation/src/sources/connect/header.rs new file mode 100644 index 0000000000..434475d74b --- /dev/null +++ b/apollo-federation/src/sources/connect/header.rs @@ -0,0 +1,317 @@ +//! Headers defined in connectors `@source` and `@connect` directives. +use std::str::FromStr; + +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::character::complete::alpha1; +use nom::character::complete::alphanumeric1; +use nom::character::complete::char; +use nom::character::complete::none_of; +use nom::combinator::map; +use nom::combinator::recognize; +use nom::multi::many0; +use nom::multi::many1; +use nom::sequence::delimited; +use nom::sequence::pair; +use nom::IResult; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use serde_json_bytes::Value as JSON; + +/// A header value, optionally containing variable references. +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct HeaderValue { + parts: Vec, +} + +impl HeaderValue { + fn new(parts: Vec) -> Self { + Self { parts } + } + + fn parse(input: &str) -> IResult<&str, Self> { + map(many1(HeaderValuePart::parse), Self::new)(input) + } + + /// Replace variable references in the header value with the given variable definitions. + /// + /// # Errors + /// Returns an error if a variable used in the header value is not defined or if a variable + /// value is not a string. + pub fn interpolate(&self, vars: &Map) -> Result { + let mut result = String::new(); + for part in &self.parts { + match part { + HeaderValuePart::Text(text) => result.push_str(text), + HeaderValuePart::Variable(var) => { + let var_path_bytes = ByteString::from(var.path.as_str()); + let value = vars + .get(&var_path_bytes) + .ok_or_else(|| format!("Missing variable: {}", var.path))?; + let value = if let JSON::String(string) = value { + string.as_str().to_string() + } else { + value.to_string() + }; + result.push_str(value.as_str()); + } + } + } + Ok(result) + } +} + +impl FromStr for HeaderValue { + type Err = String; + + fn from_str(s: &str) -> Result { + match Self::parse(s) { + Ok((_, value)) => Ok(value), + Err(e) => Err(format!("Invalid header value: {}", e)), + } + } +} + +#[derive(Debug, Eq, PartialEq, Clone)] +enum HeaderValuePart { + Text(String), + Variable(VariableReference), +} + +impl HeaderValuePart { + fn parse(input: &str) -> IResult<&str, Self> { + alt(( + map(VariableReference::parse, Self::Variable), + map(map(text, String::from), Self::Text), + ))(input) + } +} + +#[derive(Debug, Eq, PartialEq, Clone)] +struct VariableReference { + path: String, +} + +impl VariableReference { + fn new(path: String) -> Self { + Self { path } + } + + fn parse(input: &str) -> IResult<&str, Self> { + map(map(variable_reference, String::from), Self::new)(input) + } +} + +fn text(input: &str) -> IResult<&str, &str> { + recognize(many1(none_of("{")))(input) +} + +fn identifier(input: &str) -> IResult<&str, &str> { + recognize(pair( + alt((alpha1, tag("_"))), + many0(alt((alphanumeric1, tag("_")))), + ))(input) +} + +fn namespace(input: &str) -> IResult<&str, &str> { + recognize(tag("$config"))(input) +} + +fn path(input: &str) -> IResult<&str, &str> { + recognize(pair(namespace, many1(pair(char('.'), identifier))))(input) +} + +fn variable_reference(input: &str) -> IResult<&str, &str> { + delimited(char('{'), path, char('}'))(input) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[test] + fn test_identifier() { + assert_eq!(identifier("_"), Ok(("", "_"))); + assert_eq!(identifier("a"), Ok(("", "a"))); + assert_eq!(identifier("test"), Ok(("", "test"))); + assert_eq!(identifier("test123"), Ok(("", "test123"))); + assert_eq!(identifier("_test"), Ok(("", "_test"))); + assert_eq!(identifier("test_123"), Ok(("", "test_123"))); + assert_eq!(identifier("test_123 more"), Ok((" more", "test_123"))); + } + + #[test] + fn test_namespace() { + assert_eq!(namespace("$config"), Ok(("", "$config"))); + assert_eq!(namespace("$config.one"), Ok((".one", "$config"))); + assert_eq!(namespace("$config.one.two"), Ok((".one.two", "$config"))); + assert_eq!(namespace("$config}more"), Ok(("}more", "$config"))); + } + + #[test] + fn test_path() { + assert_eq!(path("$config.one"), Ok(("", "$config.one"))); + assert_eq!(path("$config.one.two"), Ok(("", "$config.one.two"))); + assert_eq!(path("$config._one._two"), Ok(("", "$config._one._two"))); + assert_eq!( + path("$config.one.two}more"), + Ok(("}more", "$config.one.two")) + ); + } + + #[test] + fn test_variable_reference() { + assert!(variable_reference("{$config}").is_err()); + assert!(variable_reference("{$not_a_namespace.one}").is_err()); + assert_eq!(variable_reference("{$config.one}"), Ok(("", "$config.one"))); + assert_eq!( + variable_reference("{$config.one.two}"), + Ok(("", "$config.one.two")) + ); + assert_eq!( + variable_reference("{$config.one}more"), + Ok(("more", "$config.one")) + ); + } + + #[test] + fn test_variable_reference_parse() { + assert_eq!( + VariableReference::parse("{$config.one}"), + Ok(( + "", + VariableReference { + path: "$config.one".to_string() + } + )) + ); + assert_eq!( + VariableReference::parse("{$config.one.two}"), + Ok(( + "", + VariableReference { + path: "$config.one.two".to_string() + } + )) + ); + } + + #[test] + fn test_text() { + assert_eq!(text("text"), Ok(("", "text"))); + assert!(text("{$config.one}").is_err()); + assert_eq!(text("text{$config.one}"), Ok(("{$config.one}", "text"))); + } + + #[test] + fn test_header_value_part_parse() { + assert_eq!( + HeaderValuePart::parse("text"), + Ok(("", HeaderValuePart::Text("text".to_string()))) + ); + assert_eq!( + HeaderValuePart::parse("{$config.one}"), + Ok(( + "", + HeaderValuePart::Variable(VariableReference { + path: "$config.one".to_string() + }) + )) + ); + assert_eq!( + HeaderValuePart::parse("text{$config.one}"), + Ok(("{$config.one}", HeaderValuePart::Text("text".to_string()))) + ); + } + + #[test] + fn test_header_value_parse() { + assert_eq!( + HeaderValue::parse("text"), + Ok(( + "", + HeaderValue { + parts: vec![HeaderValuePart::Text("text".to_string())] + } + )) + ); + assert_eq!( + HeaderValue::parse("{$config.one}"), + Ok(( + "", + HeaderValue { + parts: vec![HeaderValuePart::Variable(VariableReference { + path: "$config.one".to_string() + })] + } + )) + ); + assert_eq!( + HeaderValue::parse("text{$config.one}text"), + Ok(( + "", + HeaderValue { + parts: vec![ + HeaderValuePart::Text("text".to_string()), + HeaderValuePart::Variable(VariableReference { + path: "$config.one".to_string() + }), + HeaderValuePart::Text("text".to_string()) + ] + } + )) + ); + assert_eq!( + HeaderValue::parse(" {$config.one} "), + Ok(( + "", + HeaderValue { + parts: vec![ + HeaderValuePart::Text(" ".to_string()), + HeaderValuePart::Variable(VariableReference { + path: "$config.one".to_string() + }), + HeaderValuePart::Text(" ".to_string()) + ] + } + )) + ); + } + + #[test] + fn test_interpolate() { + let value = HeaderValue::from_str("before {$config.one} after").unwrap(); + let mut vars = Map::new(); + vars.insert("$config.one", JSON::String("foo".into())); + assert_eq!(value.interpolate(&vars), Ok("before foo after".into())); + } + + #[test] + fn test_interpolate_missing_value() { + let value = HeaderValue::from_str("{$config.one}").unwrap(); + let vars = Map::new(); + assert_eq!( + value.interpolate(&vars), + Err("Missing variable: $config.one".to_string()) + ); + } + + #[rstest] + #[case(JSON::Array(vec!["one".into(), "two".into()]), Ok("[\"one\",\"two\"]".into()))] + #[case(JSON::Bool(true), Ok("true".into()))] + #[case(JSON::Null, Ok("null".into()))] + #[case(JSON::Number(1.into()), Ok("1".into()))] + #[case(JSON::Object(Map::new()), Ok("{}".into()))] + #[case(JSON::String("string".into()), Ok("string".into()))] + fn test_interpolate_value_not_a_string( + #[case] value: JSON, + #[case] expected: Result, + ) { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = Map::new(); + vars.insert("$config.one", value); + assert_eq!(expected, header_value.interpolate(&vars)); + } +} diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index ce33b09e02..2226447bc4 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -5,6 +5,7 @@ use std::hash::Hasher; use apollo_compiler::Name; pub mod expand; +mod header; mod json_selection; mod models; pub(crate) mod spec; diff --git a/apollo-federation/src/sources/connect/models.rs b/apollo-federation/src/sources/connect/models.rs index 844360d4b5..e5e685a670 100644 --- a/apollo-federation/src/sources/connect/models.rs +++ b/apollo-federation/src/sources/connect/models.rs @@ -14,6 +14,7 @@ use super::JSONSelection; use super::URLTemplate; use crate::error::FederationError; use crate::schema::ValidFederationSchema; +use crate::sources::connect::header::HeaderValue; use crate::sources::connect::spec::extract_connect_directive_arguments; use crate::sources::connect::spec::extract_source_directive_arguments; use crate::sources::connect::ConnectSpecDefinition; @@ -219,7 +220,7 @@ impl HTTPMethod { #[derive(Clone, Debug)] pub enum HeaderSource { From(String), - Value(String), + Value(HeaderValue), } #[cfg(test)] @@ -308,7 +309,13 @@ mod tests { "X-Auth-Token", ), "user-agent": Value( - "Firefox", + HeaderValue { + parts: [ + Text( + "Firefox", + ), + ], + }, ), }, body: None, @@ -394,7 +401,13 @@ mod tests { "X-Auth-Token", ), "user-agent": Value( - "Firefox", + HeaderValue { + parts: [ + Text( + "Firefox", + ), + ], + }, ), }, body: None, diff --git a/apollo-federation/src/sources/connect/spec/directives.rs b/apollo-federation/src/sources/connect/spec/directives.rs index 64127351ba..d33b052902 100644 --- a/apollo-federation/src/sources/connect/spec/directives.rs +++ b/apollo-federation/src/sources/connect/spec/directives.rs @@ -27,6 +27,7 @@ use crate::error::FederationError; use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; use crate::schema::FederationSchema; +use crate::sources::connect::header::HeaderValue; use crate::sources::connect::json_selection::JSONSelection; use crate::sources::connect::spec::schema::CONNECT_SOURCE_ARGUMENT_NAME; use crate::sources::connect::HeaderSource; @@ -240,7 +241,14 @@ fn node_to_header(value: &Node) -> Result<(HeaderName, HeaderSource), Fed ))?; if let Some(value) = value.as_str() { - Ok((name, HeaderSource::Value(value.to_string()))) + Ok(( + name, + HeaderSource::Value( + value.parse::().map_err(|err| { + internal!(format!("Invalid header value: {}", err.to_string())) + })?, + ), + )) } else { Err(internal!( "`value` field in HTTP header mapping is not a string" @@ -524,7 +532,13 @@ mod tests { "X-Auth-Token", ), "user-agent": Value( - "Firefox", + HeaderValue { + parts: [ + Text( + "Firefox", + ), + ], + }, ), }, }, diff --git a/apollo-router/src/plugins/connectors/http_json_transport.rs b/apollo-router/src/plugins/connectors/http_json_transport.rs index 4501ff3c6d..53cec118fe 100644 --- a/apollo-router/src/plugins/connectors/http_json_transport.rs +++ b/apollo-router/src/plugins/connectors/http_json_transport.rs @@ -70,10 +70,11 @@ pub(crate) fn make_request( original_request: &connect::Request, debug: &Option>>, ) -> Result, HttpJsonTransportError> { + let flat_inputs = flatten_keys(&inputs); let uri = make_uri( transport.source_url.as_ref(), &transport.connect_template, - &inputs, + &flat_inputs, )?; let (json_body, body, apply_to_errors) = if let Some(ref selection) = transport.body { @@ -99,6 +100,7 @@ pub(crate) fn make_request( &mut request, original_request.supergraph_request.headers(), &transport.headers, + &flat_inputs, ); if let Some(debug) = debug { @@ -120,9 +122,8 @@ pub(crate) fn make_request( fn make_uri( source_url: Option<&Url>, template: &URLTemplate, - inputs: &IndexMap, + inputs: &Map, ) -> Result { - let flat_inputs = flatten_keys(inputs); let mut url = source_url .or(template.base.as_ref()) .ok_or(HttpJsonTransportError::NoBaseUrl)? @@ -135,12 +136,12 @@ fn make_uri( .pop_if_empty() .extend( template - .interpolate_path(&flat_inputs) + .interpolate_path(inputs) .map_err(HttpJsonTransportError::TemplateGenerationError)?, ); let query_params = template - .interpolate_query(&flat_inputs) + .interpolate_query(inputs) .map_err(HttpJsonTransportError::TemplateGenerationError)?; if !query_params.is_empty() { url.query_pairs_mut().extend_pairs(query_params); @@ -180,6 +181,7 @@ fn add_headers( request: &mut http::Request, incoming_supergraph_headers: &HeaderMap, config: &IndexMap, + inputs: &Map, ) { let headers = request.headers_mut(); for (header_name, header_source) in config { @@ -202,12 +204,17 @@ fn add_headers( } } } - HeaderSource::Value(value) => match HeaderValue::from_str(value) { - Ok(value) => { - headers.append(header_name, value); - } + HeaderSource::Value(value) => match value.interpolate(inputs) { + Ok(value) => match HeaderValue::from_str(value.as_str()) { + Ok(value) => { + headers.append(header_name, value); + } + Err(err) => { + tracing::error!("Invalid header value '{:?}': {:?}", value, err); + } + }, Err(err) => { - tracing::error!("Invalid header value '{}': {:?}", value, err); + tracing::error!("Unable to interpolate header value: {:?}", err); } }, } @@ -244,7 +251,7 @@ mod test_make_uri { $( map.insert($key.to_string(), json!($value)); )* - map + flatten_keys(&map) } }; } @@ -731,6 +738,7 @@ mod tests { &mut request, &incoming_supergraph_headers, &IndexMap::with_hasher(Default::default()), + &Map::default(), ); assert!(request.headers().is_empty()); } @@ -754,11 +762,16 @@ mod tests { ); config.insert( "x-insert".parse().unwrap(), - HeaderSource::Value("inserted".to_string()), + HeaderSource::Value("inserted".parse().unwrap()), ); let mut request = http::Request::builder().body(hyper::Body::empty()).unwrap(); - add_headers(&mut request, &incoming_supergraph_headers, &config); + add_headers( + &mut request, + &incoming_supergraph_headers, + &config, + &Map::default(), + ); let result = request.headers(); assert_eq!(result.len(), 3); assert_eq!(result.get("x-new-name"), Some(&"renamed".parse().unwrap())); diff --git a/apollo-router/src/plugins/connectors/testdata/steelthread.graphql b/apollo-router/src/plugins/connectors/testdata/steelthread.graphql index 8fae4a4659..bede4d394d 100644 --- a/apollo-router/src/plugins/connectors/testdata/steelthread.graphql +++ b/apollo-router/src/plugins/connectors/testdata/steelthread.graphql @@ -2,7 +2,7 @@ schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) - @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/", headers: [{name: "x-new-name", from: "x-rename-source"}, {name: "x-forward", from: "x-forward"}, {name: "x-insert", value: "inserted"}]}}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/", headers: [{name: "x-new-name", from: "x-rename-source"}, {name: "x-forward", from: "x-forward"}, {name: "x-insert", value: "inserted"}, {name: "x-config-variable-source", value: "before {$config.source.val} after"}]}}) { query: Query } @@ -67,7 +67,7 @@ type Query @join__type(graph: CONNECTORS) @join__type(graph: GRAPHQL) { - users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users", headers: [{name: "x-new-name", from: "x-rename-connect"}, {name: "x-insert-multi-value", value: "first,second"}]}, selection: "id name"}) + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users", headers: [{name: "x-new-name", from: "x-rename-connect"}, {name: "x-insert-multi-value", value: "first,second"}, {name: "x-config-variable-connect", value: "before {$config.connect.val} after"}]}, selection: "id name"}) me: User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$config.id}"}, selection: "id\nname\nusername"}) user(id: ID!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}"}, selection: "id\nname\nusername", entity: true}) posts: [Post] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/posts"}, selection: "id title user: { id: userId }"}) diff --git a/apollo-router/src/plugins/connectors/testdata/steelthread.yaml b/apollo-router/src/plugins/connectors/testdata/steelthread.yaml index 9309714164..0fcd5c7f13 100644 --- a/apollo-router/src/plugins/connectors/testdata/steelthread.yaml +++ b/apollo-router/src/plugins/connectors/testdata/steelthread.yaml @@ -1,5 +1,5 @@ # rover supergraph compose --config apollo-router/src/plugins/connectors/testdata/steelthread.yaml > apollo-router/src/plugins/connectors/testdata/steelthread.graphql -federation_version: =2.9.0-connectors.9 +federation_version: =2.9.0-connectors.12 subgraphs: connectors: routing_url: none @@ -22,6 +22,7 @@ subgraphs: { name: "x-new-name" from: "x-rename-source" } { name: "x-forward" from: "x-forward" } { name: "x-insert" value: "inserted" } + { name: "x-config-variable-source" value: "before {$$config.source.val} after" } ] } ) @@ -35,6 +36,7 @@ subgraphs: headers: [ {name: "x-new-name", from: "x-rename-connect"} {name: "x-insert-multi-value", value: "first,second"} + {name: "x-config-variable-connect" value: "before {$$config.connect.val} after"} ] } selection: "id name" diff --git a/apollo-router/src/plugins/connectors/tests.rs b/apollo-router/src/plugins/connectors/tests.rs index 76f1c0cec1..18f68ce9b7 100644 --- a/apollo-router/src/plugins/connectors/tests.rs +++ b/apollo-router/src/plugins/connectors/tests.rs @@ -435,7 +435,22 @@ async fn test_headers() { &mock_server.uri(), "query { users { id } }", Default::default(), - None, + Some(json!({ + "preview_connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + } + })), |request| { let headers = request.router_request.headers_mut(); headers.insert("x-rename-source", "renamed-by-source".parse().unwrap()); @@ -474,6 +489,14 @@ async fn test_headers() { HeaderName::from_str("x-insert-multi-value").unwrap(), HeaderValue::from_str("second").unwrap(), ) + .header( + HeaderName::from_str("x-config-variable-source").unwrap(), + HeaderValue::from_str("before val-from-config-source after").unwrap(), + ) + .header( + HeaderName::from_str("x-config-variable-connect").unwrap(), + HeaderValue::from_str("before val-from-config-connect after").unwrap(), + ) .path("/users") .build()], );