Skip to content

Commit

Permalink
feat: support 'bodyFileName' templating
Browse files Browse the repository at this point in the history
  • Loading branch information
beltram committed Jun 28, 2023
1 parent 902df7a commit f2745d1
Show file tree
Hide file tree
Showing 14 changed files with 152 additions and 96 deletions.
1 change: 1 addition & 0 deletions book/src/stubs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
3 changes: 2 additions & 1 deletion book/src/stubs/response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion lib/src/model/grpc/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponseTemplate> {
unimplemented!()
}
}
Expand Down
90 changes: 54 additions & 36 deletions lib/src/model/response/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ impl BodyStub {

fn render_json_obj(&self, json_body: &Map<String, Value>, 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()),
Expand All @@ -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(),
Expand Down Expand Up @@ -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<ResponseTemplate> {
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<Option<BodyFile>, D::Error>
Expand All @@ -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,
Expand All @@ -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() {
Expand All @@ -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<ResponseTemplate> {
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<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);
}
Ok(template)
self._render_response_template(template, data)
}
}

Expand Down
28 changes: 13 additions & 15 deletions lib/src/model/response/body_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> {
self.extension
.as_deref()
Expand All @@ -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<Value> = 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::<Value>(&content).ok()) {
return resp.set_body_json(content);
}
resp
if self.is_text() {
return resp.set_body_string(content);
}
ResponseTemplate::new(500)
}
}

Expand Down
38 changes: 18 additions & 20 deletions lib/src/model/response/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ pub struct HttpRespHeadersStub {
pub headers: Option<Map<String, Value>>,
}

impl HttpRespHeadersStub {
fn _render_response_template(&self, mut resp: ResponseTemplate, data: &HandlebarsData) -> StubrResult<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).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() {
Expand All @@ -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<ResponseTemplate> {
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<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())
}
}
}
Ok(resp)
self._render_response_template(resp, data)
}
}
30 changes: 10 additions & 20 deletions lib/src/model/response/template/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<ResponseTemplate>;

#[cfg(feature = "grpc")]
fn render_response_template(
&self, template: ResponseTemplate, data: &HandlebarsData, md: Option<&protobuf::reflect::MessageDescriptor>,
) -> StubrResult<ResponseTemplate>;

fn register<S: AsRef<str>>(&self, name: &str, content: S) {
fn register(&self, name: &str, content: impl AsRef<str>) {
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<T: Serialize>(&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<T: Serialize>(&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<T: Serialize>(&self, name: &str, data: &T) -> Option<String> {
HANDLEBARS.read().ok().and_then(|it| it.render(name, data).ok())
}
}
2 changes: 1 addition & 1 deletion lib/src/verify/mapping/resp/body/json_templating/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/verify/mapping/resp/body/text_templating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions lib/tests/resp/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
3 changes: 3 additions & 0 deletions lib/tests/stubs/resp/body/a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "a"
}
3 changes: 3 additions & 0 deletions lib/tests/stubs/resp/body/b.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "b"
}
12 changes: 12 additions & 0 deletions lib/tests/stubs/resp/body/body-file-template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"request": {
"method": "GET"
},
"response": {
"status": 200,
"bodyFileName": "tests/stubs/resp/body/{{request.pathSegments.[1]}}.json",
"transformers": [
"response-template"
]
}
}
Loading

0 comments on commit f2745d1

Please sign in to comment.