data_source/
additional_schema_source.rs1use crate::compiled_schema::keywords;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone)]
21pub struct SchemaSourceError(pub String);
22
23impl std::fmt::Display for SchemaSourceError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 f.write_str(&self.0)
26 }
27}
28
29impl std::error::Error for SchemaSourceError {}
30
31impl From<String> for SchemaSourceError {
32 fn from(s: String) -> Self {
33 Self(s)
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
48#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
49#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
50#[serde(rename_all = "camelCase")]
51pub struct AdditionalSchemaSource {
52 #[serde(default, skip_serializing_if = "Option::is_none")]
56 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
57 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
58 pub type_name: Option<String>,
59 pub schema: String,
62}
63
64impl AdditionalSchemaSource {
65 pub fn resolve(&self) -> Result<(String, serde_json::Value), SchemaSourceError> {
73 let explicit = self.type_name.as_deref().unwrap_or("");
77 let label = if explicit.is_empty() { "<unnamed>" } else { explicit };
78 let schema: serde_json::Value = serde_json::from_str(&self.schema)
79 .map_err(|e| SchemaSourceError(format!("Invalid additional schema for '{label}': {e}")))?;
80 if !schema.is_object() {
81 return Err(SchemaSourceError(format!(
82 "Invalid additional schema for '{label}': expected a JSON object describing a CloudFormation \
83 resource provider schema"
84 )));
85 }
86 if !explicit.is_empty() && explicit != explicit.trim() {
87 return Err(SchemaSourceError(format!(
88 "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
89 )));
90 }
91 let in_schema = match schema.get(keywords::TYPE_NAME) {
92 Some(value) => {
93 let declared = value.as_str().ok_or_else(|| {
94 SchemaSourceError(format!("Invalid additional schema for '{label}': 'typeName' must be a string"))
95 })?;
96 if declared != declared.trim() {
97 return Err(SchemaSourceError(format!(
98 "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
99 )));
100 }
101 (!declared.is_empty()).then_some(declared)
102 }
103 None => None,
104 };
105 let type_name = match (explicit, in_schema) {
106 ("", None) => {
107 return Err(SchemaSourceError(
108 "Additional schema is missing a resource type name (no explicit type name and no typeName in \
109 the schema)"
110 .to_string(),
111 ));
112 }
113 ("", Some(from_schema)) => from_schema.to_string(),
114 (explicit, None) => explicit.to_string(),
115 (explicit, Some(from_schema)) if explicit == from_schema => explicit.to_string(),
116 (explicit, Some(from_schema)) => {
117 return Err(SchemaSourceError(format!(
118 "Invalid additional schema for '{explicit}': the schema declares typeName '{from_schema}'; \
119 remove one of them or make them match"
120 )));
121 }
122 };
123 if type_name != type_name.trim() {
125 return Err(SchemaSourceError(format!(
126 "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
127 )));
128 }
129 Ok((type_name, schema))
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn error_msg(result: Result<(String, serde_json::Value), SchemaSourceError>) -> String {
138 result.expect_err("expected error").0
139 }
140
141 #[test]
142 fn resolve_uses_explicit_type_name() {
143 let src = AdditionalSchemaSource {
144 type_name: Some("AWS::Lambda::Function".into()),
145 schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
146 };
147 let (type_name, schema) = src.resolve().expect("valid schema resolves");
148 assert_eq!(type_name, "AWS::Lambda::Function");
149 assert!(schema.is_object());
150 }
151
152 #[test]
153 fn resolve_accepts_matching_type_names() {
154 let src = AdditionalSchemaSource {
155 type_name: Some("AWS::Lambda::Function".into()),
156 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"P":{"type":"string"}}}"#.into(),
157 };
158 let (type_name, _) = src.resolve().expect("agreeing type names resolve");
159 assert_eq!(type_name, "AWS::Lambda::Function");
160 }
161
162 #[test]
163 fn resolve_rejects_contradictory_type_names() {
164 let src = AdditionalSchemaSource {
165 type_name: Some("AWS::Lambda::Function".into()),
166 schema: r#"{"typeName":"AWS::Other::Type","properties":{}}"#.into(),
167 };
168 let message = error_msg(src.resolve());
169 assert!(message.contains("AWS::Lambda::Function") && message.contains("AWS::Other::Type"));
170 }
171
172 #[test]
173 fn resolve_falls_back_to_schema_type_name() {
174 let src = AdditionalSchemaSource {
175 type_name: None,
176 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{}}"#.into(),
177 };
178 let (type_name, _) = src.resolve().expect("valid schema resolves");
179 assert_eq!(type_name, "AWS::Lambda::Function");
180 }
181
182 #[test]
183 fn resolve_rejects_invalid_json() {
184 let src =
185 AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{ not json ".into() };
186 let message = error_msg(src.resolve());
187 assert!(message.contains("Invalid additional schema"));
188 }
189
190 #[test]
191 fn resolve_rejects_json_beyond_the_default_parser_depth_limit() {
192 const SERDE_JSON_DEFAULT_DEPTH_LIMIT: usize = 128;
193 let nesting = SERDE_JSON_DEFAULT_DEPTH_LIMIT + 1;
194 let schema = format!("{}null{}", "[".repeat(nesting), "]".repeat(nesting));
195 let src = AdditionalSchemaSource { type_name: Some("AWS::Test::Deep".into()), schema };
196
197 let message = error_msg(src.resolve());
198
199 assert!(message.contains("recursion limit exceeded"), "unexpected error: {message}");
200 }
201
202 #[test]
203 fn resolve_rejects_non_object() {
204 let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "42".into() };
205 let message = error_msg(src.resolve());
206 assert!(message.contains("expected a JSON object"));
207 }
208
209 #[test]
210 fn resolve_rejects_missing_type_name() {
211 let src = AdditionalSchemaSource { type_name: None, schema: r#"{"properties":{}}"#.into() };
212 let message = error_msg(src.resolve());
213 assert!(message.contains("missing a resource type name"));
214 }
215
216 #[test]
217 fn resolve_error_names_an_unnamed_source() {
218 let src = AdditionalSchemaSource { type_name: None, schema: "{ not json ".into() };
219 let message = error_msg(src.resolve());
220 assert!(message.contains("<unnamed>"));
221 }
222
223 #[test]
224 fn serializes_without_the_type_name_key_when_absent() {
225 let src = AdditionalSchemaSource { type_name: None, schema: "{}".into() };
226 let json = serde_json::to_value(&src).expect("serializes");
227 assert_eq!(json, serde_json::json!({ "schema": "{}" }), "an absent type name is omitted from the wire form");
228 }
229
230 #[test]
231 fn serializes_the_type_name_key_under_its_camel_case_name_when_present() {
232 let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{}".into() };
233 let json = serde_json::to_value(&src).expect("serializes");
234 assert_eq!(json, serde_json::json!({ "typeName": "AWS::Lambda::Function", "schema": "{}" }));
235 }
236
237 #[test]
238 fn deserializes_a_missing_type_name_to_none() {
239 let src: AdditionalSchemaSource = serde_json::from_str(r#"{"schema":"{}"}"#).expect("deserializes");
240 assert_eq!(src.type_name, None, "a wire form without typeName yields an absent type name");
241 assert_eq!(src.schema, "{}");
242 }
243
244 #[test]
245 fn deserializes_a_present_type_name_from_its_camel_case_key() {
246 let src: AdditionalSchemaSource =
247 serde_json::from_str(r#"{"typeName":"AWS::Lambda::Function","schema":"{}"}"#).expect("deserializes");
248 assert_eq!(src.type_name.as_deref(), Some("AWS::Lambda::Function"));
249 }
250
251 #[test]
252 fn round_trips_through_json_in_both_states() {
253 for original in [
254 AdditionalSchemaSource { type_name: None, schema: r#"{"typeName":"AWS::S3::Bucket"}"#.into() },
255 AdditionalSchemaSource { type_name: Some("AWS::S3::Bucket".into()), schema: "{}".into() },
256 ] {
257 let json = serde_json::to_string(&original).expect("serializes");
258 let restored: AdditionalSchemaSource = serde_json::from_str(&json).expect("deserializes");
259 assert_eq!(restored.type_name, original.type_name);
260 assert_eq!(restored.schema, original.schema);
261 }
262 }
263}