Skip to main content

aws_smithy_json/protocol/
aws_rest_json_1.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! AWS REST JSON 1.0 protocol implementation.
7//!
8//! This module provides [`AwsRestJsonProtocol`], which constructs an
9//! [`HttpBindingProtocol`] with a [`JsonCodec`] configured for the
10//! `aws.protocols#restJson1` protocol:
11//!
12//! - Uses `@jsonName` trait for JSON property names
13//! - Default timestamp format: `epoch-seconds`
14//! - Content-Type: `application/json`
15
16use crate::codec::{JsonCodec, JsonCodecSettings};
17use aws_smithy_schema::http_protocol::{
18    http_error_deserializer, http_output_deserializer, HttpBindingProtocol,
19};
20use aws_smithy_schema::{shape_id, Schema, ShapeId};
21use aws_smithy_types::config_bag::ConfigBag;
22
23static PROTOCOL_ID: ShapeId<'static> = shape_id!("aws.protocols", "restJson1");
24
25/// AWS REST JSON 1.0 protocol (`aws.protocols#restJson1`).
26///
27/// This is a thin configuration wrapper that constructs an [`HttpBindingProtocol`]
28/// with a [`JsonCodec`] using REST JSON settings. The `HttpBindingProtocol` handles
29/// splitting members between HTTP bindings and the JSON payload.
30#[derive(Debug)]
31pub struct AwsRestJsonProtocol {
32    inner: HttpBindingProtocol<JsonCodec>,
33}
34
35impl AwsRestJsonProtocol {
36    /// Creates a new REST JSON protocol with default settings.
37    pub fn new() -> Self {
38        let codec = JsonCodec::new(
39            JsonCodecSettings::builder()
40                .use_json_name(true)
41                .default_timestamp_format(aws_smithy_types::date_time::Format::EpochSeconds)
42                .protocol_id(PROTOCOL_ID.clone())
43                .build(),
44        );
45        Self {
46            inner: HttpBindingProtocol::new(PROTOCOL_ID.clone(), codec, "application/json"),
47        }
48    }
49
50    /// Configures the default Smithy namespace used to resolve relative
51    /// shape IDs in JSON `__type` discriminator fields. Forwarded to
52    /// [`JsonCodecSettings::default_namespace`] on the codec wrapped by
53    /// this protocol.
54    ///
55    /// REST JSON services may emit relative `__type` values in document-
56    /// typed members; code-generated clients call this method with the
57    /// service shape's namespace so the deserializer can produce a fully-
58    /// qualified discriminator.
59    ///
60    /// Setting this explicitly *overrides* the default, which is the
61    /// [`ServiceShapeNamespace`](aws_smithy_schema::protocol::ServiceShapeNamespace) config-bag
62    /// entry that generated clients store regardless of which protocol they were generated for.
63    /// That fallback exists because a customer selecting restJson1 through
64    /// `Config::builder().protocol(..)` has no way to know the model's namespace, and without it
65    /// every relative discriminator would stay unresolved. See
66    /// the internal `codec_with_bag_namespace` helper in `protocol/mod.rs` for how it is applied
67    /// and what it costs.
68    pub fn with_default_namespace(self, namespace: impl Into<String>) -> Self {
69        let new_settings = self
70            .inner
71            .codec()
72            .settings()
73            .to_builder()
74            .default_namespace(namespace)
75            .build();
76        let new_codec = JsonCodec::new(new_settings);
77        Self {
78            inner: self.inner.with_codec(new_codec),
79        }
80    }
81
82    /// Returns a reference to the inner `HttpBindingProtocol`.
83    pub fn inner(&self) -> &HttpBindingProtocol<JsonCodec> {
84        &self.inner
85    }
86}
87
88impl Default for AwsRestJsonProtocol {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl aws_smithy_schema::protocol::ClientProtocolInner for AwsRestJsonProtocol {
95    type Request = aws_smithy_runtime_api::http::Request;
96    type Response = aws_smithy_runtime_api::http::Response;
97
98    fn protocol_id(&self) -> &ShapeId<'static> {
99        self.inner.protocol_id()
100    }
101
102    fn serialize_request(
103        &self,
104        input: &dyn aws_smithy_schema::serde::SerializableStruct,
105        input_schema: &Schema<'_>,
106        endpoint: &str,
107        cfg: &ConfigBag,
108    ) -> Result<aws_smithy_runtime_api::http::Request, aws_smithy_schema::serde::SerdeError> {
109        self.inner
110            .serialize_request(input, input_schema, endpoint, cfg)
111    }
112
113    fn deserialize_response<'a>(
114        &self,
115        response: &'a aws_smithy_runtime_api::http::Response,
116        output_schema: &Schema<'_>,
117        cfg: &'a ConfigBag,
118    ) -> Result<
119        Box<dyn aws_smithy_schema::serde::ShapeDeserializer + 'a>,
120        aws_smithy_schema::serde::SerdeError,
121    > {
122        // When no namespace was configured explicitly, fall back to the one generated clients
123        // store in the config bag, so a protocol selected at runtime can still resolve relative
124        // `__type` discriminators. See `crate::protocol::codec_with_bag_namespace`.
125        if let Some(codec) = crate::protocol::codec_with_bag_namespace(self.inner.codec(), cfg) {
126            // This branch builds its own body deserializer, so it has to wrap it the same way
127            // the inner protocol would. Without that, selecting restJson1 at runtime against a
128            // client that stored its namespace in the bag would silently drop every modeled
129            // header, status, and payload member.
130            //
131            // Body extraction mirrors `HttpBindingProtocol::deserialize_response`, which carries
132            // the rationale for tolerating an unreadable (streaming) body; `&[]` means "no body
133            // members to read". Kept in step with that method.
134            let body = response.body().bytes().unwrap_or(&[]);
135            return Ok(http_output_deserializer(
136                aws_smithy_schema::codec::Codec::create_deserializer(&codec, body),
137                response,
138                output_schema,
139                cfg,
140            ));
141        }
142        self.inner
143            .deserialize_response(response, output_schema, cfg)
144    }
145
146    /// Returns a deserializer for a modeled error response.
147    ///
148    /// restJson1 has no error envelope, so the body root already *is* the error body and there is
149    /// nothing to reposition. The override is therefore **behavior-neutral today**: the default
150    /// forwarding would route the error through
151    /// [`deserialize_response`](aws_smithy_schema::protocol::ClientProtocolInner::deserialize_response), and because the JSON codec reads an
152    /// empty body as an empty object, the success wrapper's stricter empty-body handling is not
153    /// observable here. It is stated rather than relied upon.
154    ///
155    /// It is overridden anyway so that error deserialization does not *depend* on two incidental
156    /// properties of the default forwarding: that `prelude::DOCUMENT` happens not to qualify for
157    /// the body-only fast path, and that this codec happens to accept an empty body. Neither is a
158    /// contract, and an error path that silently became the success path would drop every
159    /// transport-bound member. Being explicit also keeps every REST error path structurally
160    /// incapable of reaching the success fast path, which is the property the protocol-level
161    /// error tests assert.
162    ///
163    /// The namespace fallback applies here too, so the branch structure matches
164    /// `deserialize_response`.
165    fn deserialize_error_response<'a>(
166        &self,
167        response: &'a aws_smithy_runtime_api::http::Response,
168        cfg: &'a ConfigBag,
169    ) -> Result<
170        Box<dyn aws_smithy_schema::serde::ShapeDeserializer + 'a>,
171        aws_smithy_schema::serde::SerdeError,
172    > {
173        if let Some(codec) = crate::protocol::codec_with_bag_namespace(self.inner.codec(), cfg) {
174            let body = response.body().bytes().unwrap_or(&[]);
175            return Ok(http_error_deserializer(
176                aws_smithy_schema::codec::Codec::create_deserializer(&codec, body),
177                response,
178                cfg,
179            ));
180        }
181        self.inner.deserialize_error_response(response, cfg)
182    }
183
184    /// Extracts canonical error metadata from a `restJson1` response.
185    ///
186    /// restJson1 uses the same JSON error envelope as awsJson1.0 / 1.1:
187    /// `__type` (or legacy `code`) for the error code, with header
188    /// `X-Amzn-Errortype` taking priority; `message` / `Message` /
189    /// `errorMessage` for the message.
190    ///
191    /// Per the
192    /// [`ClientProtocolInner::parse_error_metadata`](aws_smithy_schema::protocol::ClientProtocolInner::parse_error_metadata)
193    /// contract the request id is **not** populated here — the
194    /// orchestrator's request-id pipeline attaches it separately.
195    ///
196    fn parse_error_metadata(
197        &self,
198        response: &aws_smithy_runtime_api::http::Response,
199        _cfg: &ConfigBag,
200    ) -> Result<aws_smithy_types::error::metadata::Builder, aws_smithy_schema::serde::SerdeError>
201    {
202        let body = response.body().bytes().unwrap_or(&[]);
203        crate::protocol::error::parse_error_envelope_metadata(body, response.headers())
204    }
205
206    fn payload_codec(&self) -> Option<&dyn aws_smithy_schema::codec::DynCodec> {
207        self.inner.payload_codec()
208    }
209
210    /// This protocol labels structured event-stream payloads `application/json`.
211    ///
212    /// Must stay in agreement with the code generator's
213    /// `eventStreamMessageContentType` for this protocol (`RestJson.kt:90`), which supplies
214    /// the fallback when a protocol declares no media type.
215    fn event_stream_media_type(&self) -> Option<&str> {
216        Some("application/json")
217    }
218
219    /// Parses the same JSON error envelope as
220    /// [`ClientProtocolInner::parse_error_metadata`](aws_smithy_schema::protocol::ClientProtocolInner::parse_error_metadata), from an event-stream
221    /// frame's payload rather than an HTTP response body. An event-stream frame has
222    /// no HTTP headers, so the `x-amzn-errortype` route is unavailable here and an
223    /// empty header map is passed; the discriminator comes from the payload's
224    /// `__type`.
225    fn parse_event_stream_error_metadata(
226        &self,
227        payload: &[u8],
228    ) -> Result<aws_smithy_types::error::metadata::Builder, aws_smithy_schema::serde::SerdeError>
229    {
230        crate::protocol::error::parse_error_envelope_metadata(
231            payload,
232            &aws_smithy_runtime_api::http::Headers::new(),
233        )
234    }
235
236    fn update_endpoint(
237        &self,
238        request: &mut aws_smithy_runtime_api::http::Request,
239        endpoint: &aws_smithy_types::endpoint::Endpoint,
240        cfg: &ConfigBag,
241    ) -> Result<(), aws_smithy_schema::serde::SerdeError> {
242        self.inner.update_endpoint(request, endpoint, cfg)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use aws_smithy_runtime_api::http::{Response, StatusCode};
250    use aws_smithy_schema::protocol::ClientProtocolInner;
251    use aws_smithy_schema::serde::SerdeError;
252    use aws_smithy_types::body::SdkBody;
253
254    fn http_response(headers: &[(&str, &str)], body: &str) -> Response {
255        let mut response = Response::new(StatusCode::try_from(400).unwrap(), SdkBody::from(body));
256        for (name, value) in headers {
257            response
258                .headers_mut()
259                .insert(name.to_string(), value.to_string());
260        }
261        response
262    }
263
264    #[test]
265    fn protocol_id_is_rest_json_1() {
266        assert_eq!(
267            AwsRestJsonProtocol::new().protocol_id().as_str(),
268            "aws.protocols#restJson1"
269        );
270    }
271
272    #[test]
273    fn parse_error_metadata_extracts_code_and_message_from_body() {
274        let proto = AwsRestJsonProtocol::new();
275        let response = http_response(
276            &[],
277            r#"{"__type":"ValidationException","message":"bad input"}"#,
278        );
279        let cfg = ConfigBag::base();
280        let meta = proto.parse_error_metadata(&response, &cfg).unwrap().build();
281        assert_eq!(meta.code(), Some("ValidationException"));
282        assert_eq!(meta.message(), Some("bad input"));
283    }
284
285    #[test]
286    fn parse_error_metadata_header_takes_priority() {
287        let proto = AwsRestJsonProtocol::new();
288        let response = http_response(
289            &[("x-amzn-errortype", "FromHeader")],
290            r#"{"__type":"FromBody"}"#,
291        );
292        let cfg = ConfigBag::base();
293        let meta = proto.parse_error_metadata(&response, &cfg).unwrap().build();
294        assert_eq!(meta.code(), Some("FromHeader"));
295    }
296
297    #[test]
298    fn parse_error_metadata_sanitizes_namespaced_code() {
299        let proto = AwsRestJsonProtocol::new();
300        let response = http_response(&[("x-amzn-errortype", "ns#FooError:http://example/")], "");
301        let cfg = ConfigBag::base();
302        let meta = proto.parse_error_metadata(&response, &cfg).unwrap().build();
303        assert_eq!(meta.code(), Some("FooError"));
304    }
305
306    #[test]
307    fn parse_error_metadata_empty_body_returns_empty_builder() {
308        let proto = AwsRestJsonProtocol::new();
309        let response = http_response(&[], "");
310        let cfg = ConfigBag::base();
311        let meta = proto.parse_error_metadata(&response, &cfg).unwrap().build();
312        assert!(meta.code().is_none());
313        assert!(meta.message().is_none());
314    }
315
316    #[test]
317    fn parse_error_metadata_malformed_body_returns_error() {
318        let proto = AwsRestJsonProtocol::new();
319        let response = http_response(&[], r#"{"__type":"Foo""#);
320        let cfg = ConfigBag::base();
321        let err = proto.parse_error_metadata(&response, &cfg).unwrap_err();
322        assert!(matches!(err, SerdeError::InvalidInput { .. }));
323    }
324
325    #[test]
326    fn with_default_namespace_propagates_to_codec_settings() {
327        let proto = AwsRestJsonProtocol::new().with_default_namespace("com.amazonaws.s3");
328        assert_eq!(
329            proto.inner.codec().settings().default_namespace(),
330            Some("com.amazonaws.s3"),
331        );
332    }
333
334    #[test]
335    fn with_default_namespace_preserves_other_settings() {
336        // Rebuilding the codec for `default_namespace` must preserve
337        // the restJson1-specific `@jsonName=true` field-mapper choice
338        // set in `AwsRestJsonProtocol::new`.
339        let proto = AwsRestJsonProtocol::new().with_default_namespace("com.example");
340        let settings = proto.inner.codec().settings();
341        assert_eq!(settings.default_namespace(), Some("com.example"));
342        assert_eq!(
343            settings.default_timestamp_format(),
344            aws_smithy_types::date_time::Format::EpochSeconds,
345        );
346    }
347
348    // ---------------------------------------------------------------------------------
349    // the ConfigBag-namespace branch must wrap like the inner protocol does
350    //
351    // That branch builds its own body deserializer and returns early, so it is the one place
352    // restJson1 can silently lose every transport-bound member.
353    // ---------------------------------------------------------------------------------
354
355    use aws_smithy_schema::protocol::ServiceShapeNamespace;
356    use aws_smithy_schema::ShapeType;
357    use aws_smithy_types::config_bag::Layer;
358
359    static NS_NAME: Schema<'static> =
360        Schema::new_member(shape_id!("test", "Out"), ShapeType::String, "name", 0)
361            .with_http_header("x-name");
362    static NS_STATUS: Schema<'static> =
363        Schema::new_member(shape_id!("test", "Out"), ShapeType::Integer, "status", 1)
364            .with_http_response_code();
365    static NS_BODY: Schema<'static> =
366        Schema::new_member(shape_id!("test", "Out"), ShapeType::String, "note", 2);
367    static NS_OUT: Schema<'static> = Schema::new_struct(
368        shape_id!("test", "Out"),
369        ShapeType::Structure,
370        &[&NS_NAME, &NS_STATUS, &NS_BODY],
371    );
372
373    /// A bag carrying the namespace a generated client would have stored, which is what sends
374    /// `deserialize_response` down its own branch instead of delegating to the inner protocol.
375    fn bag_with_namespace() -> ConfigBag {
376        let mut layer = Layer::new("test");
377        layer.store_put(ServiceShapeNamespace::new("com.amazonaws.dynamodb"));
378        ConfigBag::of_layers(vec![layer])
379    }
380
381    fn read_bound(
382        mut deser: Box<dyn aws_smithy_schema::serde::ShapeDeserializer + '_>,
383        schema: &Schema<'_>,
384    ) -> (Option<String>, Option<i32>) {
385        let mut name = None;
386        let mut status = None;
387        deser
388            .read_struct(schema, &mut |member, d| {
389                match member.member_name() {
390                    Some("name") => name = Some(d.read_string(member)?),
391                    Some("status") => status = Some(d.read_integer(member)?),
392                    // Read and discard. A consumer that declines a *known* member leaves a
393                    // cursor-based codec mid-value, which is the same reason the response
394                    // wrapper calls `skip_value` rather than just returning.
395                    _ => {
396                        let _ = d.read_string(member)?;
397                    }
398                }
399                Ok(())
400            })
401            .expect("deserialization succeeds");
402        (name, status)
403    }
404
405    #[test]
406    fn the_namespace_branch_still_reads_modeled_headers_and_status() {
407        let proto = AwsRestJsonProtocol::new();
408        let cfg = bag_with_namespace();
409        assert!(
410            crate::protocol::codec_with_bag_namespace(proto.inner().codec(), &cfg).is_some(),
411            "precondition: this response must take the namespace branch"
412        );
413        let response = http_response(&[("x-name", "widget")], r#"{"note":"n"}"#);
414        let (name, status) = read_bound(
415            proto
416                .deserialize_response(&response, &NS_OUT, &cfg)
417                .unwrap(),
418            &NS_OUT,
419        );
420        assert_eq!(name.as_deref(), Some("widget"));
421        assert_eq!(status, Some(400));
422    }
423
424    #[test]
425    fn the_error_path_reads_modeled_headers_and_status_too() {
426        let proto = AwsRestJsonProtocol::new();
427        let cfg = bag_with_namespace();
428        let response = http_response(&[("x-name", "missing")], r#"{"note":"n"}"#);
429        let (name, status) = read_bound(
430            proto.deserialize_error_response(&response, &cfg).unwrap(),
431            &NS_OUT,
432        );
433        assert_eq!(name.as_deref(), Some("missing"));
434        assert_eq!(status, Some(400));
435    }
436
437    #[test]
438    fn the_error_path_reads_bindings_without_the_namespace_branch_too() {
439        // With no namespace in the bag the method delegates to the inner protocol's error path.
440        // Both branches must behave the same way.
441        let proto = AwsRestJsonProtocol::new();
442        let cfg = ConfigBag::base();
443        assert!(
444            crate::protocol::codec_with_bag_namespace(proto.inner().codec(), &cfg).is_none(),
445            "precondition: this response must delegate to the inner protocol"
446        );
447        let response = http_response(&[("x-name", "missing")], r#"{"note":"n"}"#);
448        let (name, status) = read_bound(
449            proto.deserialize_error_response(&response, &cfg).unwrap(),
450            &NS_OUT,
451        );
452        assert_eq!(name.as_deref(), Some("missing"));
453        assert_eq!(status, Some(400));
454    }
455}