Skip to main content

aws_smithy_query/
protocol.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use aws_smithy_runtime_api::client::orchestrator::Metadata;
7use aws_smithy_runtime_api::http::{Request, Response};
8use aws_smithy_schema::protocol::{apply_http_endpoint, ClientProtocolInner, ServiceVersion};
9use aws_smithy_schema::serde::{
10    SerdeError, SerializableStruct, ShapeDeserializer, ShapeSerializer,
11};
12use aws_smithy_schema::{shape_id, Schema, ShapeId};
13use aws_smithy_types::body::SdkBody;
14use aws_smithy_types::config_bag::ConfigBag;
15use aws_smithy_xml::codec::find_depth2_element_slice_by;
16
17use crate::codec::serializer::QueryShapeSerializer;
18
19#[derive(Debug)]
20pub struct AwsQueryProtocol {
21    protocol_id: ShapeId<'static>,
22    /// The `Version=` form parameter. `None` means "resolve from the config bag", which is the
23    /// normal case — see [`Self::with_service_version`].
24    service_version: Option<String>,
25}
26
27impl AwsQueryProtocol {
28    /// Creates an awsQuery protocol instance.
29    ///
30    /// The `Version=` form parameter defaults to the Smithy service shape's version from the
31    /// config bag; use [`Self::with_service_version`] to override it.
32    pub fn new() -> Self {
33        Self {
34            protocol_id: shape_id!("aws.protocols", "awsQuery"),
35            service_version: None,
36        }
37    }
38
39    /// Overrides the `Version=` form parameter.
40    ///
41    /// By default it comes from the [`ServiceVersion`] config-bag entry that generated clients
42    /// store regardless of which protocol they were generated for. That default exists because a
43    /// customer selecting awsQuery through `Config::builder().protocol(..)` has no way to know the
44    /// model's version, and awsQuery requires the parameter on every request.
45    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
46        self.service_version = Some(version.into());
47        self
48    }
49}
50
51impl Default for AwsQueryProtocol {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl ClientProtocolInner for AwsQueryProtocol {
58    type Request = Request;
59    type Response = Response;
60
61    fn protocol_id(&self) -> &ShapeId<'static> {
62        &self.protocol_id
63    }
64
65    /// Serializes an awsQuery request.
66    ///
67    /// `_endpoint` is deliberately ignored: awsQuery fixes the request path at `/` (its
68    /// `StaticHttpBindingResolver` uses `@http(method: "POST", uri: "/")`), so the route is a
69    /// function of the protocol rather than of the operation, and a path computed by codegen for a
70    /// different protocol must not leak through when this protocol is selected at runtime via
71    /// `Config::builder().protocol(..)`. `apply_http_endpoint` merges the scheme and authority
72    /// afterwards.
73    fn serialize_request(
74        &self,
75        input: &dyn SerializableStruct,
76        input_schema: &Schema<'_>,
77        _endpoint: &str,
78        cfg: &ConfigBag,
79    ) -> Result<Request, SerdeError> {
80        let op_name = cfg
81            .load::<Metadata>()
82            .map(|m| m.name().to_string())
83            .ok_or_else(|| {
84                SerdeError::custom(
85                    "operation Metadata is required to serialize an awsQuery request (Action=)",
86                )
87            })?;
88
89        let service_version = self
90            .service_version
91            .as_deref()
92            .or_else(|| cfg.load::<ServiceVersion>().map(ServiceVersion::as_str))
93            .ok_or_else(|| {
94                SerdeError::custom(
95                    "a service version is required to serialize an awsQuery request (Version=); \
96                     it is normally read from the ServiceVersion config-bag entry stored by \
97                     generated clients, or can be set with AwsQueryProtocol::with_service_version",
98                )
99            })?;
100
101        let mut serializer = QueryShapeSerializer::new(&op_name, service_version);
102        serializer.write_struct(input_schema, input)?;
103        let body = aws_smithy_schema::codec::FinishSerializer::finish(serializer);
104
105        let uri = "/";
106        let mut request = Request::new(SdkBody::from(body));
107        request
108            .set_method("POST")
109            .map_err(|e| SerdeError::custom(format!("{e}")))?;
110        request
111            .set_uri(uri)
112            .map_err(|e| SerdeError::custom(format!("{e}")))?;
113        request
114            .headers_mut()
115            .insert("Content-Type", "application/x-www-form-urlencoded");
116        if let Some(len) = request.body().content_length() {
117            request
118                .headers_mut()
119                .insert("Content-Length", len.to_string());
120        }
121        Ok(request)
122    }
123
124    fn deserialize_response<'a>(
125        &self,
126        response: &'a Response,
127        _output_schema: &Schema<'_>,
128        _cfg: &'a ConfigBag,
129    ) -> Result<Box<dyn ShapeDeserializer + 'a>, SerdeError> {
130        use aws_smithy_schema::codec::Codec;
131        use aws_smithy_xml::codec::{XmlCodec, XmlCodecSettings};
132
133        let body = response
134            .body()
135            .bytes()
136            .ok_or_else(|| SerdeError::custom("response body not available"))?;
137        let body_str =
138            std::str::from_utf8(body).map_err(|e| SerdeError::invalid_input(e.to_string()))?;
139
140        // Strip the AWS Query response envelope down to the `<...Result>` (or
141        // `<Error>`) element, inclusive of its tags, so the XML deserializer
142        // can treat it as the output struct's root wrapper element (the merged
143        // `XmlDeserializer::read_struct` reads members from the root element's
144        // children).
145        let inner = strip_aws_query_envelope(body_str);
146
147        // AWS Query deserializes responses as XML with a default timestamp
148        // format of `date-time` (per the protocol spec). `XmlCodec` is
149        // stateless; the returned deserializer borrows `inner` (and therefore
150        // `response`), not the local codec — `create_deserializer` clones the
151        // shared settings `Arc` into the deserializer.
152        let codec = XmlCodec::new(
153            XmlCodecSettings::builder()
154                .default_timestamp_format(aws_smithy_types::date_time::Format::DateTime)
155                .build(),
156        );
157        Ok(Box::new(codec.create_deserializer(inner.as_bytes())))
158    }
159
160    fn update_endpoint(
161        &self,
162        request: &mut Request,
163        endpoint: &aws_smithy_types::endpoint::Endpoint,
164        cfg: &ConfigBag,
165    ) -> Result<(), SerdeError> {
166        apply_http_endpoint(request, endpoint, cfg)
167    }
168}
169
170/// Extracts the result (or error) element from an AWS Query XML response
171/// envelope, returning the element *inclusive of its tags*.
172///
173/// AWS Query responses are shaped like:
174/// ```xml
175/// <OperationNameResponse>
176///   <OperationNameResult> ... </OperationNameResult>
177///   <ResponseMetadata>...</ResponseMetadata>
178/// </OperationNameResponse>
179/// ```
180/// or, for errors:
181/// ```xml
182/// <ErrorResponse><Error> ... </Error></ErrorResponse>
183/// ```
184/// We locate the depth-2 element whose local name ends with `Result` or equals
185/// `Error` and return its full `<El>...</El>` slice so it can be handed to an
186/// `XmlDeserializer` as the output struct's root wrapper element.
187///
188/// Delegates the depth-2 lookup to `aws_smithy_xml`'s shared
189/// [`find_depth2_element_slice_by`] (the same utility the REST XML error path
190/// uses). If no such element is found — or the body isn't valid XML — we fall
191/// back to the whole body and let the downstream `XmlDeserializer` surface any
192/// error, mirroring the REST XML fallback.
193fn strip_aws_query_envelope(xml: &str) -> &str {
194    match find_depth2_element_slice_by(xml.as_bytes(), |name| {
195        name.ends_with("Result") || name == "Error"
196    }) {
197        // The returned slice is a sub-slice of `xml` bounded by ASCII `<`/`>`,
198        // so it is always valid UTF-8.
199        Some(slice) => std::str::from_utf8(slice).unwrap_or(xml),
200        None => xml,
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use aws_smithy_schema::protocol::ClientProtocolInner;
208    use aws_smithy_schema::serde::ShapeSerializer;
209    use aws_smithy_schema::ShapeType;
210    use aws_smithy_types::config_bag::Layer;
211
212    struct EmptyInput;
213    impl SerializableStruct for EmptyInput {
214        fn schema(&self) -> &Schema<'_> {
215            &SCHEMA
216        }
217
218        fn serialize_members(&self, _: &mut dyn ShapeSerializer) -> Result<(), SerdeError> {
219            Ok(())
220        }
221    }
222
223    static SCHEMA: Schema<'static> = Schema::new(shape_id!("test", "Input"), ShapeType::Structure);
224
225    fn cfg_with_metadata() -> ConfigBag {
226        let mut layer = Layer::new("test");
227        layer.store_put(Metadata::new("GetUser", "MyService"));
228        ConfigBag::of_layers(vec![layer])
229    }
230
231    /// The bag a schema-serde generated client actually builds: operation `Metadata` plus the
232    /// model's service version.
233    fn cfg_with_service_version(version: &'static str) -> ConfigBag {
234        let mut layer = Layer::new("test");
235        layer.store_put(Metadata::new("GetUser", "MyService"));
236        layer.store_put(aws_smithy_schema::protocol::ServiceVersion::new(version));
237        ConfigBag::of_layers(vec![layer])
238    }
239
240    /// awsQuery puts the service version on the wire as `Version=`, and a customer selecting this
241    /// protocol through `Config::builder().protocol(..)` has no way to know it, so it defaults
242    /// from the config-bag entry generated clients store regardless of their protocol.
243    #[test]
244    fn service_version_defaults_from_config_bag() {
245        let cfg = cfg_with_service_version("2012-11-05");
246        let request = AwsQueryProtocol::new()
247            .serialize_request(&EmptyInput, &SCHEMA, "/", &cfg)
248            .unwrap();
249        let body = std::str::from_utf8(request.body().bytes().unwrap()).unwrap();
250        assert!(
251            body.contains("Version=2012-11-05"),
252            "expected Version= from the config bag, got {body}"
253        );
254    }
255
256    /// An explicit version still wins over the bag.
257    #[test]
258    fn with_service_version_overrides_config_bag() {
259        let cfg = cfg_with_service_version("2012-11-05");
260        let request = AwsQueryProtocol::new()
261            .with_service_version("1999-01-01")
262            .serialize_request(&EmptyInput, &SCHEMA, "/", &cfg)
263            .unwrap();
264        let body = std::str::from_utf8(request.body().bytes().unwrap()).unwrap();
265        assert!(
266            body.contains("Version=1999-01-01"),
267            "explicit version must win, got {body}"
268        );
269    }
270
271    /// `Version=` is required by the protocol, so with neither an override nor a bag entry there is
272    /// no correct request to send. Fail loudly rather than emit one the service will reject —
273    /// mirroring how absent `Metadata` is handled for `Action=`.
274    #[test]
275    fn missing_service_version_is_an_error() {
276        let cfg = cfg_with_metadata();
277        let err = AwsQueryProtocol::new()
278            .serialize_request(&EmptyInput, &SCHEMA, "/", &cfg)
279            .expect_err("no version is available");
280        assert!(
281            err.to_string().contains("version"),
282            "error should name the missing version, got: {err}"
283        );
284    }
285
286    #[test]
287    fn request_has_correct_content_type() {
288        let cfg = cfg_with_metadata();
289        let request = AwsQueryProtocol::new()
290            .with_service_version("2012-11-05")
291            .serialize_request(&EmptyInput, &SCHEMA, "https://example.com", &cfg)
292            .unwrap();
293        assert_eq!(
294            request.headers().get("Content-Type").unwrap(),
295            "application/x-www-form-urlencoded"
296        );
297    }
298
299    #[test]
300    fn request_has_action_and_version() {
301        let cfg = cfg_with_metadata();
302        let request = AwsQueryProtocol::new()
303            .with_service_version("2012-11-05")
304            .serialize_request(&EmptyInput, &SCHEMA, "https://example.com", &cfg)
305            .unwrap();
306        let body = std::str::from_utf8(request.body().bytes().unwrap()).unwrap();
307        assert!(body.contains("Action=GetUser"));
308        assert!(body.contains("Version=2012-11-05"));
309    }
310
311    /// awsQuery fixes the request path at `/` (its `StaticHttpBindingResolver` uses
312    /// `@http(method: "POST", uri: "/")`), so a path computed by codegen for a *different*
313    /// protocol must not win when this protocol is selected at runtime via
314    /// `Config::builder().protocol(..)`. That is the only way this method is reached today,
315    /// since awsQuery is not yet on the schema-serde allowlist — so the path it is handed
316    /// was always computed for some other protocol.
317    ///
318    /// This is the mirror image of https://github.com/smithy-lang/smithy-rs/issues/4801,
319    /// where the CBOR protocol failed to apply its own route.
320    ///
321    /// Note the `endpoint` argument is a *path*, not a host: `apply_http_endpoint` merges the
322    /// scheme and authority later. An earlier version of this test passed a host and asserted
323    /// it was echoed back, which documented the pass-through rather than the protocol's rule.
324    #[test]
325    fn request_ignores_a_route_computed_for_another_protocol() {
326        let cfg = cfg_with_metadata();
327        for foreign_route in ["/service/MyService/operation/GetUser", "/stats"] {
328            let request = AwsQueryProtocol::new()
329                .with_service_version("1.0")
330                .serialize_request(&EmptyInput, &SCHEMA, foreign_route, &cfg)
331                .unwrap();
332            assert_eq!(
333                request.uri(),
334                "/",
335                "awsQuery must POST to / regardless of the path it is handed"
336            );
337            assert_eq!(request.method(), "POST");
338        }
339    }
340
341    #[test]
342    fn request_defaults_to_slash() {
343        let cfg = cfg_with_metadata();
344        let request = AwsQueryProtocol::new()
345            .with_service_version("1.0")
346            .serialize_request(&EmptyInput, &SCHEMA, "", &cfg)
347            .unwrap();
348        assert_eq!(request.uri(), "/");
349    }
350
351    #[test]
352    fn deserialize_response_strips_envelope() {
353        let xml = "<GetUserResponse><GetUserResult><Name>Alice</Name><Age>30</Age></GetUserResult></GetUserResponse>";
354        let response = Response::new(200u16.try_into().unwrap(), SdkBody::from(xml));
355
356        static NAME: Schema<'static> =
357            Schema::new_member(shape_id!("t", "S"), ShapeType::String, "Name", 0);
358        static AGE: Schema<'static> =
359            Schema::new_member(shape_id!("t", "S"), ShapeType::Integer, "Age", 1);
360        static OUT_SCHEMA: Schema<'static> =
361            Schema::new_struct(shape_id!("t", "S"), ShapeType::Structure, &[&NAME, &AGE]);
362
363        let base_cfg = ConfigBag::base();
364        let mut deser = AwsQueryProtocol::new()
365            .with_service_version("1.0")
366            .deserialize_response(&response, &OUT_SCHEMA, &base_cfg)
367            .unwrap();
368        let mut name = String::new();
369        let mut age = 0i32;
370        deser
371            .read_struct(&OUT_SCHEMA, &mut |member, d| {
372                match member.member_name() {
373                    Some("Name") => name = d.read_string(member)?,
374                    Some("Age") => age = d.read_integer(member)?,
375                    _ => {}
376                }
377                Ok(())
378            })
379            .unwrap();
380        assert_eq!(name, "Alice");
381        assert_eq!(age, 30);
382    }
383
384    /// awsQuery must ignore HTTP response bindings, reading every member from the XML envelope.
385    ///
386    /// This protocol builds its own deserializer over the stripped `<...Result>` element rather
387    /// than delegating to [`HttpRpcProtocol`](aws_smithy_schema::http_protocol::HttpRpcProtocol),
388    /// so it does not inherit that type's body-only guarantee and needs its own guard. Envelope
389    /// positioning and binding applicability are independent concerns: restXml positions its
390    /// error deserializer the same way *and* reads bindings, whereas this protocol positions and
391    /// reads nothing from the transport.
392    #[test]
393    fn deserialize_response_ignores_http_response_bindings() {
394        let xml = "<GetUserResponse><GetUserResult><Name>from-body</Name></GetUserResult></GetUserResponse>";
395        let mut response = Response::new(418u16.try_into().unwrap(), SdkBody::from(xml));
396        response
397            .headers_mut()
398            .insert("x-name", "from-header".to_string());
399
400        static BOUND_NAME: Schema<'static> =
401            Schema::new_member(shape_id!("t", "S"), ShapeType::String, "Name", 0)
402                .with_http_header("x-name");
403        static BOUND_STATUS: Schema<'static> =
404            Schema::new_member(shape_id!("t", "S"), ShapeType::Integer, "Status", 1)
405                .with_http_response_code();
406        static BOUND_SCHEMA: Schema<'static> = Schema::new_struct(
407            shape_id!("t", "S"),
408            ShapeType::Structure,
409            &[&BOUND_NAME, &BOUND_STATUS],
410        );
411
412        let mut name = None;
413        let mut status = None;
414        AwsQueryProtocol::new()
415            .with_service_version("1.0")
416            .deserialize_response(&response, &BOUND_SCHEMA, &ConfigBag::base())
417            .unwrap()
418            .read_struct(&BOUND_SCHEMA, &mut |member, d| {
419                match member.member_name() {
420                    Some("Name") => name = Some(d.read_string(member)?),
421                    Some("Status") => status = Some(d.read_integer(member)?),
422                    _ => {}
423                }
424                Ok(())
425            })
426            .unwrap();
427        assert_eq!(
428            name.as_deref(),
429            Some("from-body"),
430            "a bound member must come from the XML body, never from the header"
431        );
432        assert_eq!(
433            status, None,
434            "an `@httpResponseCode` member has no body representation here, so it stays absent"
435        );
436    }
437
438    #[test]
439    fn strip_envelope_returns_self_closing_result_element() {
440        // A self-closing result element (empty output) must still be returned
441        // inclusive of its tags, not fall through to the whole-document root.
442        let xml = "<GetUserResponse><GetUserResult/><ResponseMetadata><RequestId>r</RequestId></ResponseMetadata></GetUserResponse>";
443        assert_eq!(strip_aws_query_envelope(xml), "<GetUserResult/>");
444    }
445
446    #[test]
447    fn strip_envelope_returns_error_element() {
448        let xml = "<ErrorResponse><Error><Code>Boom</Code></Error></ErrorResponse>";
449        assert_eq!(
450            strip_aws_query_envelope(xml),
451            "<Error><Code>Boom</Code></Error>"
452        );
453    }
454
455    #[test]
456    fn protocol_id() {
457        assert_eq!(
458            AwsQueryProtocol::new()
459                .with_service_version("1.0")
460                .protocol_id()
461                .as_str(),
462            "aws.protocols#awsQuery"
463        );
464    }
465}