Skip to main content

aws_smithy_cbor/
protocol.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! RPC v2 CBOR protocol implementation.
7
8use crate::codec::{CborCodec, CborCodecSettings};
9use crate::Decoder;
10use aws_smithy_runtime_api::client::orchestrator::Metadata;
11use aws_smithy_runtime_api::http::{Headers, Request, Response};
12use aws_smithy_schema::error_envelope::{parse_query_compatible_header, sanitize_error_code};
13use aws_smithy_schema::http_protocol::HttpRpcProtocol;
14use aws_smithy_schema::protocol::{ClientProtocolInner, ServiceShapeName};
15use aws_smithy_schema::serde::{SerdeError, SerializableStruct, ShapeDeserializer};
16use aws_smithy_schema::{shape_id, Schema, ShapeId};
17use aws_smithy_types::config_bag::ConfigBag;
18use aws_smithy_types::error::metadata::{Builder as ErrorMetadataBuilder, ErrorMetadata};
19
20/// RPC v2 CBOR protocol (`smithy.protocols#rpcv2Cbor`).
21#[derive(Debug)]
22pub struct RpcV2CborProtocol {
23    inner: HttpRpcProtocol<CborCodec>,
24}
25
26impl RpcV2CborProtocol {
27    /// Creates a new RPC v2 CBOR protocol instance.
28    pub fn new() -> Self {
29        Self {
30            inner: HttpRpcProtocol::new(
31                shape_id!("smithy.protocols", "rpcv2Cbor"),
32                CborCodec::new(CborCodecSettings::default()),
33                "application/cbor",
34            ),
35        }
36    }
37}
38
39impl Default for RpcV2CborProtocol {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl ClientProtocolInner for RpcV2CborProtocol {
46    type Request = Request;
47    type Response = Response;
48
49    fn protocol_id(&self) -> &ShapeId<'static> {
50        self.inner.protocol_id()
51    }
52
53    fn serialize_request(
54        &self,
55        input: &dyn SerializableStruct,
56        input_schema: &Schema<'_>,
57        endpoint: &str,
58        cfg: &ConfigBag,
59    ) -> Result<Request, SerdeError> {
60        // RPC v2 CBOR ignores HTTP binding traits entirely and routes to
61        // `/service/{serviceName}/operation/{operationName}`. Resolve that route
62        // here rather than trusting the path codegen passed in: because the
63        // protocol can be swapped at runtime, `endpoint` may have been computed
64        // for a different protocol (`/` for a client generated against awsJson,
65        // or an `@http` URI for a REST one), and honoring it sends every request
66        // to the wrong route. See https://github.com/smithy-lang/smithy-rs/issues/4801.
67        //
68        // Falls back to the supplied endpoint when the model names aren't
69        // available — e.g. a hand-written caller invoking this protocol directly
70        // without populating the config bag.
71        let route = rpc_v2_cbor_route(cfg);
72        let endpoint = route.as_deref().unwrap_or(endpoint);
73        let mut request = self
74            .inner
75            .serialize_request(input, input_schema, endpoint, cfg)?;
76
77        // Protocol framing, set here for the same reason as the route: it is a
78        // function of the protocol, not of the operation or its model, so a client
79        // generated for another protocol never emitted it. `smithy-protocol` is
80        // required on every request and is what conformant servers route on —
81        // smithy-rs's own server rejects its absence with
82        // `WireFormatError::HeaderNotFound`.
83        //
84        // Codegen inserts headers *after* this returns and `Headers::insert`
85        // replaces, so an operation that needs a different value (an event stream
86        // sends `accept: application/vnd.amazon.eventstream, application/cbor`)
87        // still overrides these without any coordination here.
88        request
89            .headers_mut()
90            .insert("smithy-protocol", "rpc-v2-cbor");
91        request.headers_mut().insert("accept", "application/cbor");
92
93        Ok(request)
94    }
95
96    fn deserialize_response<'a>(
97        &self,
98        response: &'a Response,
99        output_schema: &Schema<'_>,
100        cfg: &'a ConfigBag,
101    ) -> Result<Box<dyn ShapeDeserializer + 'a>, SerdeError> {
102        self.inner
103            .deserialize_response(response, output_schema, cfg)
104    }
105
106    fn payload_codec(&self) -> Option<&dyn aws_smithy_schema::codec::DynCodec> {
107        self.inner.payload_codec()
108    }
109
110    /// This protocol labels structured event-stream payloads `application/cbor`.
111    ///
112    /// Must stay in agreement with the code generator's
113    /// `eventStreamMessageContentType` for this protocol (`RpcV2Cbor.kt:115`), which supplies
114    /// the fallback when a protocol declares no media type.
115    fn event_stream_media_type(&self) -> Option<&str> {
116        Some("application/cbor")
117    }
118
119    /// Parses the same CBOR error envelope as
120    /// [`ClientProtocolInner::parse_error_metadata`], from an event-stream
121    /// frame's payload rather than an HTTP response body. An event-stream frame has
122    /// no HTTP headers, so an empty header map is passed.
123    fn parse_event_stream_error_metadata(
124        &self,
125        payload: &[u8],
126    ) -> Result<ErrorMetadataBuilder, SerdeError> {
127        parse_error_envelope_metadata(payload, &Headers::new())
128    }
129
130    fn update_endpoint(
131        &self,
132        request: &mut Request,
133        endpoint: &aws_smithy_types::endpoint::Endpoint,
134        cfg: &ConfigBag,
135    ) -> Result<(), SerdeError> {
136        self.inner.update_endpoint(request, endpoint, cfg)
137    }
138
139    fn parse_error_metadata(
140        &self,
141        response: &Response,
142        _cfg: &ConfigBag,
143    ) -> Result<ErrorMetadataBuilder, SerdeError> {
144        let body = response.body().bytes().unwrap_or(&[]);
145        parse_error_envelope_metadata(body, response.headers())
146    }
147}
148
149/// Resolves the canonical RPC v2 CBOR request route from the config bag.
150///
151/// Per the [spec](https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html#requests),
152/// requests are sent to `/service/{serviceName}/operation/{operationName}`, using
153/// the Smithy shape names of the service and the operation.
154///
155/// The service shape name comes from [`ServiceShapeName`] (stored by generated
156/// clients for whichever protocol ends up being used) and the operation shape
157/// name from [`Metadata`]. Returns `None` when either is absent, leaving the
158/// caller to fall back to the endpoint it was given.
159fn rpc_v2_cbor_route(cfg: &ConfigBag) -> Option<String> {
160    let service = cfg.load::<ServiceShapeName>()?;
161    let operation = cfg.load::<Metadata>()?;
162    Some(format!(
163        "/service/{}/operation/{}",
164        service.as_str(),
165        operation.name()
166    ))
167}
168
169/// Parses the canonical CBOR error envelope. The envelope is a CBOR map at the
170/// document root containing `__type` (the error code, optionally namespaced
171/// and/or URL-suffixed in the same way as JSON envelopes) and an optional
172/// message field (`message`, `Message`, or `errorMessage`).
173///
174/// `headers` is consulted only for the queryCompatible header
175/// (`X-Amzn-Query-Error`); when present, it overrides any body-derived code
176/// and stores the AWS error type as a `type` extra. Per the rpcv2Cbor spec,
177/// non-queryCompatible services do not emit this header, so the override is
178/// a no-op for them.
179///
180/// Returns an empty builder when the body is empty (and the queryCompatible
181/// header is absent).
182pub(crate) fn parse_error_envelope_metadata(
183    response_body: &[u8],
184    response_headers: &Headers,
185) -> Result<ErrorMetadataBuilder, SerdeError> {
186    let mut builder = if response_body.is_empty() {
187        ErrorMetadata::builder()
188    } else {
189        parse_error_body(response_body)?
190    };
191
192    // queryCompatible override — see comment on the corresponding helper in
193    // `aws-smithy-json::protocol::error`.
194    if let Some((qc_code, qc_type)) = parse_query_compatible_header(response_headers) {
195        builder = builder.code(qc_code).custom("type", qc_type);
196    }
197
198    Ok(builder)
199}
200
201fn parse_error_body(response_body: &[u8]) -> Result<ErrorMetadataBuilder, SerdeError> {
202    let decoder = &mut Decoder::new(response_body);
203    let mut builder = ErrorMetadata::builder();
204
205    match decoder.map().map_err(deser_err)? {
206        // Indefinite-length map: read entries until a `Break` token.
207        None => loop {
208            match decoder.datatype().map_err(deser_err)? {
209                crate::data::Type::Break => {
210                    decoder.skip().map_err(deser_err)?;
211                    break;
212                }
213                _ => {
214                    builder = error_code_and_message(builder, decoder)?;
215                }
216            }
217        },
218        // Definite-length map: read exactly `n` entries.
219        Some(n) => {
220            for _ in 0..n {
221                builder = error_code_and_message(builder, decoder)?;
222            }
223        }
224    }
225
226    Ok(builder)
227}
228
229fn error_code_and_message(
230    mut builder: ErrorMetadataBuilder,
231    decoder: &mut Decoder,
232) -> Result<ErrorMetadataBuilder, SerdeError> {
233    let key = decoder.str().map_err(deser_err)?;
234    builder = match key.as_ref() {
235        "__type" => {
236            // Silently skip if the value isn't a string, mirroring the
237            // message-key handling below. A malformed error code
238            // shouldn't prevent the rest of the envelope (e.g. the
239            // message) from being recovered.
240            match decoder.str() {
241                Ok(code) => builder.code(sanitize_error_code(&code)),
242                Err(_) => builder,
243            }
244        }
245        "message" | "Message" | "errorMessage" => {
246            // Silently skip if the value isn't a string. Custom error
247            // structures may use non-string types under these keys.
248            match decoder.str() {
249                Ok(message) => builder.message(message),
250                Err(_) => builder,
251            }
252        }
253        _ => {
254            decoder.skip().map_err(deser_err)?;
255            builder
256        }
257    };
258    Ok(builder)
259}
260
261fn deser_err(e: crate::decode::DeserializeError) -> SerdeError {
262    SerdeError::invalid_input(e.to_string())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::Encoder;
269    use aws_smithy_runtime_api::http::{Response, StatusCode};
270    use aws_smithy_types::body::SdkBody;
271    use aws_smithy_types::config_bag::ConfigBag;
272
273    fn cbor_response(body: Vec<u8>) -> Response {
274        Response::new(StatusCode::try_from(400).unwrap(), SdkBody::from(body))
275    }
276
277    fn encode_envelope(type_value: Option<&str>, message_value: Option<(&str, &str)>) -> Vec<u8> {
278        let mut entries: Vec<(&str, &str)> = vec![];
279        if let Some(t) = type_value {
280            entries.push(("__type", t));
281        }
282        if let Some((k, v)) = message_value {
283            entries.push((k, v));
284        }
285        let mut encoder = Encoder::new(Vec::new());
286        encoder.map(entries.len());
287        for (k, v) in &entries {
288            encoder.str(k).str(v);
289        }
290        encoder.into_writer()
291    }
292
293    #[test]
294    fn parse_error_metadata_extracts_code_and_message() {
295        let body = encode_envelope(Some("InvalidGreeting"), Some(("message", "Hi")));
296        let response = cbor_response(body);
297        let cfg = ConfigBag::base();
298        let protocol = RpcV2CborProtocol::new();
299
300        let meta = protocol
301            .parse_error_metadata(&response, &cfg)
302            .expect("parse succeeds")
303            .build();
304
305        assert_eq!(meta.code(), Some("InvalidGreeting"));
306        assert_eq!(meta.message(), Some("Hi"));
307    }
308
309    #[test]
310    fn parse_error_metadata_sanitizes_namespaced_code() {
311        let body = encode_envelope(
312            Some("aws.protocoltests.rpcv2cbor#InvalidGreeting:http://example/"),
313            None,
314        );
315        let response = cbor_response(body);
316        let cfg = ConfigBag::base();
317        let protocol = RpcV2CborProtocol::new();
318
319        let meta = protocol
320            .parse_error_metadata(&response, &cfg)
321            .expect("parse succeeds")
322            .build();
323
324        assert_eq!(meta.code(), Some("InvalidGreeting"));
325    }
326
327    #[test]
328    fn parse_error_metadata_accepts_alternate_message_keys() {
329        for key in &["Message", "errorMessage"] {
330            let body = encode_envelope(Some("X"), Some((key, "msg")));
331            let response = cbor_response(body);
332            let cfg = ConfigBag::base();
333            let protocol = RpcV2CborProtocol::new();
334
335            let meta = protocol
336                .parse_error_metadata(&response, &cfg)
337                .expect("parse succeeds")
338                .build();
339
340            assert_eq!(meta.message(), Some("msg"), "key={}", key);
341        }
342    }
343
344    #[test]
345    fn parse_error_metadata_empty_body_returns_empty_builder() {
346        let response = cbor_response(Vec::new());
347        let cfg = ConfigBag::base();
348        let protocol = RpcV2CborProtocol::new();
349
350        let meta = protocol
351            .parse_error_metadata(&response, &cfg)
352            .expect("parse succeeds")
353            .build();
354
355        assert_eq!(meta.code(), None);
356        assert_eq!(meta.message(), None);
357    }
358
359    #[test]
360    fn parse_error_metadata_malformed_body_returns_error() {
361        // 0xff is a Break token at top level — not a valid CBOR document root.
362        let response = cbor_response(vec![0xff]);
363        let cfg = ConfigBag::base();
364        let protocol = RpcV2CborProtocol::new();
365
366        let err = protocol
367            .parse_error_metadata(&response, &cfg)
368            .expect_err("malformed body should fail");
369        assert!(
370            matches!(err, SerdeError::InvalidInput { .. }),
371            "expected InvalidInput, got {:?}",
372            err
373        );
374    }
375
376    #[test]
377    fn parse_error_metadata_ignores_unknown_keys() {
378        // Encode a 3-entry map with an unknown key in the middle, so the
379        // decoder must `skip()` past its value to continue reading.
380        let mut encoder = Encoder::new(Vec::new());
381        encoder.map(3);
382        encoder.str("__type").str("InvalidGreeting");
383        encoder.str("not_a_known_key").str("ignore me");
384        encoder.str("message").str("Hi");
385        let body = encoder.into_writer();
386        let response = cbor_response(body);
387        let cfg = ConfigBag::base();
388        let protocol = RpcV2CborProtocol::new();
389
390        let meta = protocol
391            .parse_error_metadata(&response, &cfg)
392            .expect("parse succeeds")
393            .build();
394
395        assert_eq!(meta.code(), Some("InvalidGreeting"));
396        assert_eq!(meta.message(), Some("Hi"));
397    }
398
399    #[test]
400    fn parse_error_metadata_query_compat_header_overrides_body_code() {
401        // The body says `__type: CustomCodeError` (the shape name); the
402        // queryCompatible header says `Customized;Sender` — codegen dispatch
403        // keys for queryCompatible services use the awsQueryError code
404        // (`Customized`), so the header MUST win.
405        let body = encode_envelope(Some("CustomCodeError"), Some(("message", "Hi")));
406        let mut response = cbor_response(body);
407        response.headers_mut().insert(
408            "x-amzn-query-error".to_string(),
409            "Customized;Sender".to_string(),
410        );
411        let cfg = ConfigBag::base();
412        let protocol = RpcV2CborProtocol::new();
413
414        let meta = protocol
415            .parse_error_metadata(&response, &cfg)
416            .expect("parse succeeds")
417            .build();
418
419        assert_eq!(meta.code(), Some("Customized"));
420        assert_eq!(meta.message(), Some("Hi"));
421        assert_eq!(meta.extra("type"), Some("Sender"));
422    }
423
424    /// A minimal input with no members, enough to drive `serialize_request`.
425    struct EmptyInput;
426    impl aws_smithy_schema::serde::SerializableStruct for EmptyInput {
427        fn schema(&self) -> &Schema<'_> {
428            &INPUT_SCHEMA
429        }
430
431        fn serialize_members(
432            &self,
433            _: &mut dyn aws_smithy_schema::serde::ShapeSerializer,
434        ) -> Result<(), SerdeError> {
435            Ok(())
436        }
437    }
438
439    static INPUT_SCHEMA: Schema<'static> = Schema::new(
440        aws_smithy_schema::shape_id!("smithy.example", "GetStatsInput"),
441        aws_smithy_schema::ShapeType::Structure,
442    );
443
444    /// Builds a config bag holding the model names a generated client stores.
445    fn cfg_with_names(service: &'static str, operation: &'static str) -> ConfigBag {
446        let mut layer = aws_smithy_types::config_bag::Layer::new("test");
447        layer.store_put(ServiceShapeName::new(service));
448        layer.store_put(Metadata::new(operation, "Some Sdk Id"));
449        ConfigBag::of_layers(vec![layer])
450    }
451
452    fn serialize_with(cfg: &ConfigBag, endpoint: &str) -> Request {
453        RpcV2CborProtocol::new()
454            .serialize_request(&EmptyInput, &INPUT_SCHEMA, endpoint, cfg)
455            .expect("serialization succeeds")
456    }
457
458    #[test]
459    fn serialize_request_uses_canonical_rpc_route() {
460        let cfg = cfg_with_names("PokemonService", "GetServerStatistics");
461        let request = serialize_with(&cfg, "");
462        assert_eq!(
463            "/service/PokemonService/operation/GetServerStatistics",
464            request.uri()
465        );
466        assert_eq!("POST", request.method());
467    }
468
469    /// `smithy-protocol` and `accept` are required on every RPC v2 CBOR request and
470    /// are a function of the protocol alone, so this protocol must set them itself
471    /// rather than rely on codegen having emitted them — a client generated for
472    /// another protocol never did. Unlike the route, these do *not* fall back to
473    /// anything: they are unconditional.
474    #[test]
475    fn serialize_request_sets_protocol_framing_headers() {
476        let cfg = cfg_with_names("PokemonService", "GetServerStatistics");
477        let request = serialize_with(&cfg, "");
478        assert_eq!(
479            Some("rpc-v2-cbor"),
480            request.headers().get("smithy-protocol")
481        );
482        assert_eq!(Some("application/cbor"), request.headers().get("accept"));
483    }
484
485    /// The framing headers do not depend on the config bag, so they are still set
486    /// when the model names are absent and the route falls back to the supplied
487    /// endpoint.
488    #[test]
489    fn serialize_request_sets_framing_headers_without_model_names() {
490        let cfg = ConfigBag::base();
491        let request = serialize_with(&cfg, "/some/path");
492        assert_eq!("/some/path", request.uri());
493        assert_eq!(
494            Some("rpc-v2-cbor"),
495            request.headers().get("smithy-protocol")
496        );
497        assert_eq!(Some("application/cbor"), request.headers().get("accept"));
498    }
499
500    /// The route is protocol-mandated, so a path computed by codegen for a
501    /// *different* protocol must not win when this protocol is plugged in at
502    /// runtime. `/` is what an awsJson-generated client passes, and `/stats` is
503    /// what a REST-generated client's `@http` URI would look like.
504    /// See https://github.com/smithy-lang/smithy-rs/issues/4801.
505    #[test]
506    fn serialize_request_overrides_endpoint_from_another_protocol() {
507        let cfg = cfg_with_names("PokemonService", "GetServerStatistics");
508        for endpoint in ["/", "/stats", "/service/Wrong/operation/Wrong"] {
509            let request = serialize_with(&cfg, endpoint);
510            assert_eq!(
511                "/service/PokemonService/operation/GetServerStatistics",
512                request.uri(),
513                "endpoint {endpoint:?} must not override the protocol's route",
514            );
515        }
516    }
517
518    /// Without the model names in the config bag there is nothing to resolve, so
519    /// the supplied endpoint is used as-is. This keeps direct (non-generated)
520    /// callers of the protocol API working.
521    #[test]
522    fn serialize_request_falls_back_to_supplied_endpoint() {
523        let cfg = ConfigBag::base();
524        assert_eq!(
525            "/service/Fallback/operation/Op",
526            serialize_with(&cfg, "/service/Fallback/operation/Op").uri()
527        );
528        // And with no endpoint at all, `HttpRpcProtocol`'s `/` default applies.
529        assert_eq!("/", serialize_with(&cfg, "").uri());
530    }
531
532    /// `Metadata` alone is not enough — the service shape name is not derivable
533    /// from it, since `Metadata::service` is the sdkId rather than the shape name.
534    #[test]
535    fn serialize_request_falls_back_when_only_metadata_is_present() {
536        let mut layer = aws_smithy_types::config_bag::Layer::new("test");
537        layer.store_put(Metadata::new("GetServerStatistics", "Pokemon Service"));
538        let cfg = ConfigBag::of_layers(vec![layer]);
539        assert_eq!("/", serialize_with(&cfg, "").uri());
540    }
541
542    /// The service name may be materialized at runtime (e.g. read from a parsed
543    /// model) rather than being a codegen-emitted literal.
544    #[test]
545    fn service_shape_name_accepts_runtime_strings() {
546        let owned: String = ["Pokemon", "Service"].concat();
547        let mut layer = aws_smithy_types::config_bag::Layer::new("test");
548        layer.store_put(ServiceShapeName::new(owned));
549        layer.store_put(Metadata::new("GetServerStatistics", "Pokemon Service"));
550        let cfg = ConfigBag::of_layers(vec![layer]);
551        assert_eq!(
552            "/service/PokemonService/operation/GetServerStatistics",
553            serialize_with(&cfg, "").uri()
554        );
555    }
556}