diff --git a/book/src/stubs/index.md b/book/src/stubs/index.md index 29a322be..817ecbf5 100644 --- a/book/src/stubs/index.md +++ b/book/src/stubs/index.md @@ -70,6 +70,7 @@ You will find here in a single snippet **ALL** the fields/helpers available to y "body": "Hello World !", // text response (automatically adds 'Content-Type:text/plain' header) "base64Body": "AQID", // binary Base 64 body "bodyFileName": "tests/stubs/response.json", // path to a .json or .txt file containing the response + "bodyFileName": "tests/stubs/{{request.pathSegments.[1]}}.json", // supports templating "headers": { "content-type": "application/pdf" // returns this response header }, diff --git a/book/src/stubs/response.md b/book/src/stubs/response.md index cab3f445..27da3564 100644 --- a/book/src/stubs/response.md +++ b/book/src/stubs/response.md @@ -61,7 +61,8 @@ but you can relax all those fields with templates. We'll see that immediately in for `bodyFileName`. * `base64Body` if the body is not utf-8 encoded use it to supply a body as byte. Those have to be base 64 encoded. * `bodyFileName` when the response gets large or to factorize some very common bodies, it is sometimes preferable to - extract it in a file. When using it in a Rust project, the file path is relative to the workspace root. + extract it in a file. When using it in a Rust project, the file path is relative to the workspace root. You can also + use templating to dynamically select a file. * `jsonBody` when the body is json. Even though such a body can be defined with all the previous fields, it is more convenient to define a json response body here. diff --git a/lib/src/model/grpc/response.rs b/lib/src/model/grpc/response.rs index 55c276bb..4d87eda0 100644 --- a/lib/src/model/grpc/response.rs +++ b/lib/src/model/grpc/response.rs @@ -55,7 +55,7 @@ impl HandlebarTemplatable for GrpcResponseStub { } #[cfg(not(feature = "grpc"))] - fn render_response_template(&self, mut _template: ResponseTemplate, _data: &HandlebarsData) -> ResponseTemplate { + fn render_response_template(&self, mut _template: ResponseTemplate, _data: &HandlebarsData) -> StubrResult { unimplemented!() } } diff --git a/lib/src/model/response/body.rs b/lib/src/model/response/body.rs index 48c8602d..72dea722 100644 --- a/lib/src/model/response/body.rs +++ b/lib/src/model/response/body.rs @@ -67,7 +67,7 @@ impl BodyStub { fn render_json_obj(&self, json_body: &Map, data: &HandlebarsData) -> Value { let obj = json_body.into_iter().map(|(key, value)| match value { - Value::String(s) => (key.to_owned(), Self::cast_to_value(self.render(s, data))), + Value::String(s) => (key.to_owned(), Self::cast_to_value(self.render(s, data).unwrap_or_default())), Value::Object(o) => (key.to_owned(), self.render_json_obj(o, data)), Value::Array(a) => (key.to_owned(), self.render_json_array(a, data)), _ => (key.to_owned(), value.to_owned()), @@ -80,7 +80,7 @@ impl BodyStub { json_body .iter() .map(|value| match value { - Value::String(s) => Self::cast_to_value(self.render(s, data)), + Value::String(s) => Self::cast_to_value(self.render(s, data).unwrap_or_default()), Value::Object(o) => self.render_json_obj(o, data), Value::Array(a) => self.render_json_array(a, data), _ => value.to_owned(), @@ -115,6 +115,39 @@ impl BodyStub { .as_ref() .and_then(|b| base64::prelude::BASE64_STANDARD.decode(b).ok()) } + + fn _render_response_template(&self, template: ResponseTemplate, data: &HandlebarsData) -> StubrResult { + if let Some(body) = self.body.as_ref() { + return Ok(template.set_body_string(self.render(body, data).unwrap_or_default())); + } + if let Some(binary) = self.binary_body() { + return Ok(template.set_body_bytes(binary)); + } + if let Some(json_body) = self.render_json_body(self.json_body.as_ref(), data) { + return Ok(template.set_body_json(json_body)); + } + if let Some(body_file) = self.body_file_name.as_ref() { + return if let Some(path) = &self.render(&body_file.canonicalize_path(), data) { + if self.has_template(path) { + let rendered_content = self.render(path, data).unwrap_or_default(); + return Ok(body_file.render_templated(template, rendered_content)); + } + let file = PathBuf::from(path); + if file.exists() { + let content = read_file(&file); + // register for next uses to be faster + self.register(path, content); + let rendered_content = self.render(path, data).unwrap_or_default(); + return Ok(body_file.render_templated(template, rendered_content)); + } + Ok(ResponseTemplate::new(404)) + } else { + let rendered_content = self.render(body_file.path.as_str(), data).unwrap_or_default(); + Ok(body_file.render_templated(template, rendered_content)) + }; + } + Ok(template) + } } fn deserialize_body_file<'de, D>(path: D) -> Result, D::Error> @@ -125,16 +158,7 @@ where let body_file = String::deserialize(path).ok().map(PathBuf::from).map(|path| { let path_exists = path.exists(); let extension = path.extension().and_then(OsStr::to_str).map(str::to_string); - let content = OpenOptions::new() - .read(true) - .open(&path) - .ok() - .and_then(|mut file| { - let mut buf = vec![]; - file.read_to_end(&mut buf).map(|_| buf).ok() - }) - .and_then(|bytes| from_utf8(bytes.as_slice()).map(str::to_string).ok()) - .unwrap_or_default(); + let content = read_file(&path); let path = path.to_str().map(str::to_string).unwrap_or_default(); BodyFile { path_exists, @@ -146,6 +170,19 @@ where Ok(body_file) } +fn read_file(path: &PathBuf) -> String { + OpenOptions::new() + .read(true) + .open(path) + .ok() + .and_then(|mut file| { + let mut buf = vec![]; + file.read_to_end(&mut buf).map(|_| buf).ok() + }) + .and_then(|bytes| from_utf8(bytes.as_slice()).map(str::to_string).ok()) + .unwrap_or_default() +} + impl HandlebarTemplatable for BodyStub { fn register_template(&self) { if let Some(body) = self.body.as_ref() { @@ -157,40 +194,21 @@ impl HandlebarTemplatable for BodyStub { self.register_json_body_template(array.iter()); } } else if let Some(body_file) = self.body_file_name.as_ref() { + self.register(&body_file.canonicalize_path(), body_file.path.as_str()); self.register(body_file.path.as_str(), &body_file.content); } } #[cfg(not(feature = "grpc"))] - fn render_response_template(&self, mut template: ResponseTemplate, data: &HandlebarsData) -> ResponseTemplate { - if let Some(body) = self.body.as_ref() { - template = template.set_body_string(self.render(body, data)); - } else if let Some(binary) = self.binary_body() { - template = template.set_body_bytes(binary); - } else if let Some(json_body) = self.render_json_body(self.json_body.as_ref(), data) { - template = template.set_body_json(json_body); - } else if let Some(body_file) = self.body_file_name.as_ref() { - let rendered = self.render(body_file.path.as_str(), data); - template = body_file.render_templated(template, rendered); - } - template + fn render_response_template(&self, mut template: ResponseTemplate, data: &HandlebarsData) -> StubrResult { + self._render_response_template(template, data) } #[cfg(feature = "grpc")] fn render_response_template( - &self, mut template: ResponseTemplate, data: &HandlebarsData, _md: Option<&protobuf::reflect::MessageDescriptor>, + &self, template: ResponseTemplate, data: &HandlebarsData, _md: Option<&protobuf::reflect::MessageDescriptor>, ) -> StubrResult { - if let Some(body) = self.body.as_ref() { - template = template.set_body_string(self.render(body, data)); - } else if let Some(binary) = self.binary_body() { - template = template.set_body_bytes(binary); - } else if let Some(json_body) = self.render_json_body(self.json_body.as_ref(), data) { - template = template.set_body_json(json_body); - } else if let Some(body_file) = self.body_file_name.as_ref() { - let rendered = self.render(body_file.path.as_str(), data); - template = body_file.render_templated(template, rendered); - } - Ok(template) + self._render_response_template(template, data) } } diff --git a/lib/src/model/response/body_file.rs b/lib/src/model/response/body_file.rs index 61706509..475b99a3 100644 --- a/lib/src/model/response/body_file.rs +++ b/lib/src/model/response/body_file.rs @@ -16,6 +16,8 @@ impl BodyFile { const JSON_EXT: &'static str = "json"; const TEXT_EXT: &'static str = "txt"; + const BODY_FILE_NAME_PREFIX: &'static str = "STUBR_BODY_FILE_NAME_TEMPLATE_PREFIX_"; + fn maybe_as_json(&self) -> Option { self.extension .as_deref() @@ -37,25 +39,21 @@ impl BodyFile { fn is_text(&self) -> bool { self.extension.as_deref().map(|ext| ext == Self::TEXT_EXT).unwrap_or_default() } + + pub(crate) fn canonicalize_path(&self) -> String { + format!("{}{}", Self::BODY_FILE_NAME_PREFIX, self.path) + } } impl BodyFile { - pub fn render_templated(&self, mut resp: ResponseTemplate, content: String) -> ResponseTemplate { - if !self.path_exists { - resp = ResponseTemplate::new(500) - } else if self.is_json() { - let maybe_content: Option = serde_json::from_str(&content).ok(); - if let Some(content) = maybe_content { - resp = resp.set_body_json(content); - } else { - resp = ResponseTemplate::new(500) - } - } else if self.is_text() { - resp = resp.set_body_string(content); - } else { - resp = ResponseTemplate::new(500) + pub fn render_templated(&self, resp: ResponseTemplate, content: String) -> ResponseTemplate { + if let Some(content) = self.is_json().then_some(serde_json::from_str::(&content).ok()) { + return resp.set_body_json(content); } - resp + if self.is_text() { + return resp.set_body_string(content); + } + ResponseTemplate::new(500) } } diff --git a/lib/src/model/response/headers.rs b/lib/src/model/response/headers.rs index 3c14ea22..97358830 100644 --- a/lib/src/model/response/headers.rs +++ b/lib/src/model/response/headers.rs @@ -13,6 +13,20 @@ pub struct HttpRespHeadersStub { pub headers: Option>, } +impl HttpRespHeadersStub { + fn _render_response_template(&self, mut resp: ResponseTemplate, data: &HandlebarsData) -> StubrResult { + if let Some(headers) = self.headers.as_ref() { + for (k, v) in headers { + if let Some(v) = v.as_str() { + let rendered = self.render(v, data).unwrap_or_default(); + resp = resp.insert_header(k.as_str(), rendered.as_str()) + } + } + } + Ok(resp) + } +} + impl ResponseAppender for HttpRespHeadersStub { fn add(&self, mut resp: ResponseTemplate) -> ResponseTemplate { if let Some(headers) = self.headers.as_ref() { @@ -38,30 +52,14 @@ impl HandlebarTemplatable for HttpRespHeadersStub { } #[cfg(not(feature = "grpc"))] - fn render_response_template(&self, mut resp: ResponseTemplate, data: &HandlebarsData) -> ResponseTemplate { - if let Some(headers) = self.headers.as_ref() { - for (k, v) in headers { - if let Some(v) = v.as_str() { - let rendered = self.render(v, data); - resp = resp.insert_header(k.as_str(), rendered.as_str()) - } - } - } - resp + fn render_response_template(&self, resp: ResponseTemplate, data: &HandlebarsData) -> StubrResult { + self._render_response_template(resp, data) } #[cfg(feature = "grpc")] fn render_response_template( - &self, mut resp: ResponseTemplate, data: &HandlebarsData, _md: Option<&protobuf::reflect::MessageDescriptor>, + &self, resp: ResponseTemplate, data: &HandlebarsData, _md: Option<&protobuf::reflect::MessageDescriptor>, ) -> StubrResult { - if let Some(headers) = self.headers.as_ref() { - for (k, v) in headers { - if let Some(v) = v.as_str() { - let rendered = self.render(v, data); - resp = resp.insert_header(k.as_str(), rendered.as_str()) - } - } - } - Ok(resp) + self._render_response_template(resp, data) } } diff --git a/lib/src/model/response/template/mod.rs b/lib/src/model/response/template/mod.rs index ea59f9e0..724351d2 100644 --- a/lib/src/model/response/template/mod.rs +++ b/lib/src/model/response/template/mod.rs @@ -101,8 +101,8 @@ impl StubTemplate { stub_name: None, is_verify: false, }; - resp = response.body.render_response_template(resp, &data); - resp = response.headers.render_response_template(resp, &data); + resp = response.body.render_response_template(resp, &data)?; + resp = response.headers.render_response_template(resp, &data)?; } Ok(resp) } @@ -171,36 +171,26 @@ pub trait HandlebarTemplatable { fn register_template(&self); #[cfg(not(feature = "grpc"))] - fn render_response_template(&self, template: ResponseTemplate, data: &HandlebarsData) -> ResponseTemplate; + fn render_response_template(&self, template: ResponseTemplate, data: &HandlebarsData) -> StubrResult; #[cfg(feature = "grpc")] fn render_response_template( &self, template: ResponseTemplate, data: &HandlebarsData, md: Option<&protobuf::reflect::MessageDescriptor>, ) -> StubrResult; - fn register>(&self, name: &str, content: S) { + fn register(&self, name: &str, content: impl AsRef) { if let Ok(mut handlebars) = HANDLEBARS.write() { handlebars.register_template_string(name, content).unwrap_or_default(); } } - /// Template has to be registered first before being rendered here - /// Better for performances - fn render(&self, name: &str, data: &T) -> String { - HANDLEBARS - .read() - .ok() - .and_then(|it| it.render(name, data).ok()) - .unwrap_or_default() + fn has_template(&self, name: &str) -> bool { + HANDLEBARS.read().map(|h| h.has_template(name)).unwrap_or_default() } - /// Template does not have to be registered first - /// Simpler - fn render_template(&self, name: &str, data: &T) -> String { - HANDLEBARS - .read() - .ok() - .and_then(|it| it.render_template(name, data).ok()) - .unwrap_or_default() + /// Template has to be registered first before being rendered here + /// Better for performances + fn render(&self, name: &str, data: &T) -> Option { + HANDLEBARS.read().ok().and_then(|it| it.render(name, data).ok()) } } diff --git a/lib/src/verify/mapping/resp/body/json_templating/object.rs b/lib/src/verify/mapping/resp/body/json_templating/object.rs index 671651ab..9e1e17eb 100644 --- a/lib/src/verify/mapping/resp/body/json_templating/object.rs +++ b/lib/src/verify/mapping/resp/body/json_templating/object.rs @@ -92,7 +92,7 @@ impl Verifier<'_> for JsonObjectVerifier<'_> { stub_name: Some(name), }; stub.body.register(expected, expected); - let render = stub.body.render(expected, &data); + let render = stub.body.render(expected, &data).unwrap_or_default(); if expected.is_predictable() { assert_eq!( va, diff --git a/lib/src/verify/mapping/resp/body/text_templating.rs b/lib/src/verify/mapping/resp/body/text_templating.rs index 17ab0b3e..789d5507 100644 --- a/lib/src/verify/mapping/resp/body/text_templating.rs +++ b/lib/src/verify/mapping/resp/body/text_templating.rs @@ -23,7 +23,7 @@ impl Verifier<'_> for TextBodyTemplatingVerifier { stub_name: Some(name), }; stub.body.register(&self.expected, &self.expected); - let expected = stub.body.render(&self.expected, &data); + let expected = stub.body.render(&self.expected, &data).unwrap_or_default(); if self.expected.is_predictable() { assert_eq!( self.actual, expected, diff --git a/lib/tests/resp/body.rs b/lib/tests/resp/body.rs index 65d20c77..e918a367 100644 --- a/lib/tests/resp/body.rs +++ b/lib/tests/resp/body.rs @@ -166,3 +166,28 @@ mod file { get(stubr.uri()).await.expect_status_internal_server_error(); } } + +mod file_template { + use super::*; + + #[async_std::test] + #[stubr::mock("resp/body/body-file-template.json")] + async fn from_file_with_template_should_succeed() { + get(stubr.path("/body/a")) + .await + .expect_status_ok() + .expect_body_json_eq(json!({"name": "a"})) + .expect_content_type_json(); + get(stubr.path("/body/b")) + .await + .expect_status_ok() + .expect_body_json_eq(json!({"name": "b"})) + .expect_content_type_json(); + } + + #[async_std::test] + #[stubr::mock("resp/body/body-file-template.json")] + async fn from_file_with_template_should_fail() { + get(stubr.path("/body/c")).await.expect_status_not_found(); + } +} diff --git a/lib/tests/stubs/resp/body/a.json b/lib/tests/stubs/resp/body/a.json new file mode 100644 index 00000000..1aeaf2c8 --- /dev/null +++ b/lib/tests/stubs/resp/body/a.json @@ -0,0 +1,3 @@ +{ + "name": "a" +} \ No newline at end of file diff --git a/lib/tests/stubs/resp/body/b.json b/lib/tests/stubs/resp/body/b.json new file mode 100644 index 00000000..1c67e0d0 --- /dev/null +++ b/lib/tests/stubs/resp/body/b.json @@ -0,0 +1,3 @@ +{ + "name": "b" +} \ No newline at end of file diff --git a/lib/tests/stubs/resp/body/body-file-template.json b/lib/tests/stubs/resp/body/body-file-template.json new file mode 100644 index 00000000..e02a48c5 --- /dev/null +++ b/lib/tests/stubs/resp/body/body-file-template.json @@ -0,0 +1,12 @@ +{ + "request": { + "method": "GET" + }, + "response": { + "status": 200, + "bodyFileName": "tests/stubs/resp/body/{{request.pathSegments.[1]}}.json", + "transformers": [ + "response-template" + ] + } +} diff --git a/schemas/stubr.schema.json b/schemas/stubr.schema.json index a68cacab..b2264db8 100644 --- a/schemas/stubr.schema.json +++ b/schemas/stubr.schema.json @@ -337,7 +337,14 @@ }, "bodyFileName": { "description": "Relative path to a .json or .txt file containing response body", - "type": "string" + "type": "string", + "patternProperties": { + "^.*$": { + "x-intellij-language-injection": { + "language": "Handlebars" + } + } + } }, "jsonBody": { "description": "Json response body, adds 'Content-Type:application/json' header in the response",