From 0f955c0aeb6348ec99c8d533dbab308eb3db03d3 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod Date: Mon, 26 Aug 2024 13:58:17 -0400 Subject: [PATCH 1/4] move `json` to `md` --- src/cli/generator/config.rs | 3 + .../{gen_deezer.json => gen_deezer.md} | 2 + ...nfig.json => gen_json_proto_mix_config.md} | 4 +- ...laceholder.json => gen_jsonplaceholder.md} | 2 + tests/cli/gen.rs | 202 ++++++++++++++++-- tests/cli/parser.rs | 132 ++++++++++++ 6 files changed, 326 insertions(+), 19 deletions(-) rename tests/cli/fixtures/generator/{gen_deezer.json => gen_deezer.md} (98%) rename tests/cli/fixtures/generator/{gen_json_proto_mix_config.json => gen_json_proto_mix_config.md} (83%) rename tests/cli/fixtures/generator/{gen_jsonplaceholder.json => gen_jsonplaceholder.md} (98%) create mode 100644 tests/cli/parser.rs diff --git a/src/cli/generator/config.rs b/src/cli/generator/config.rs index dab568cb42..5bdf9d6d2d 100644 --- a/src/cli/generator/config.rs +++ b/src/cli/generator/config.rs @@ -25,6 +25,8 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub preset: Option, pub schema: Schema, + #[serde(default, skip_serializing_if = "TemplateString::is_empty")] + pub secret: TemplateString, #[serde(skip_serializing_if = "Option::is_none")] pub llm: Option, } @@ -293,6 +295,7 @@ impl Config { output, schema: self.schema, preset: self.preset, + secret: self.secret, llm, }) } diff --git a/tests/cli/fixtures/generator/gen_deezer.json b/tests/cli/fixtures/generator/gen_deezer.md similarity index 98% rename from tests/cli/fixtures/generator/gen_deezer.json rename to tests/cli/fixtures/generator/gen_deezer.md index 42e2aafd1e..6fb4f47ac9 100644 --- a/tests/cli/fixtures/generator/gen_deezer.json +++ b/tests/cli/fixtures/generator/gen_deezer.md @@ -1,3 +1,4 @@ +```json @config { "inputs": [ { @@ -63,3 +64,4 @@ "query": "Query" } } +``` diff --git a/tests/cli/fixtures/generator/gen_json_proto_mix_config.json b/tests/cli/fixtures/generator/gen_json_proto_mix_config.md similarity index 83% rename from tests/cli/fixtures/generator/gen_json_proto_mix_config.json rename to tests/cli/fixtures/generator/gen_json_proto_mix_config.md index a521d34e29..1da85764b2 100644 --- a/tests/cli/fixtures/generator/gen_json_proto_mix_config.json +++ b/tests/cli/fixtures/generator/gen_json_proto_mix_config.md @@ -1,3 +1,4 @@ +```json @config { "inputs": [ { @@ -8,7 +9,7 @@ }, { "proto": { - "src": "../../../../../../tailcall-fixtures/fixtures/protobuf/news.proto" + "src": "tailcall-fixtures/fixtures/protobuf/news.proto" } } ], @@ -26,3 +27,4 @@ "query": "Query" } } +``` diff --git a/tests/cli/fixtures/generator/gen_jsonplaceholder.json b/tests/cli/fixtures/generator/gen_jsonplaceholder.md similarity index 98% rename from tests/cli/fixtures/generator/gen_jsonplaceholder.json rename to tests/cli/fixtures/generator/gen_jsonplaceholder.md index cc81a23152..2feead7b4f 100644 --- a/tests/cli/fixtures/generator/gen_jsonplaceholder.json +++ b/tests/cli/fixtures/generator/gen_jsonplaceholder.md @@ -1,3 +1,4 @@ +```json @config { "inputs": [ { @@ -81,3 +82,4 @@ "query": "Query" } } +``` diff --git a/tests/cli/gen.rs b/tests/cli/gen.rs index fa70fd4488..90955a5fa1 100644 --- a/tests/cli/gen.rs +++ b/tests/cli/gen.rs @@ -1,6 +1,166 @@ +mod parser; + +pub mod cacache_manager { + use std::io::{Read, Write}; + use std::path::PathBuf; + + use flate2::write::GzEncoder; + use flate2::Compression; + use http_cache_reqwest::{CacheManager, HttpResponse}; + use http_cache_semantics::CachePolicy; + use serde::{Deserialize, Serialize}; + + pub type BoxError = Box; + pub type Result = std::result::Result; + + pub struct CaCacheManager { + path: PathBuf, + } + + #[derive(Clone, Deserialize, Serialize)] + pub struct Store { + response: HttpResponse, + policy: CachePolicy, + } + + impl Default for CaCacheManager { + fn default() -> Self { + Self { path: PathBuf::from("./.cache") } + } + } + + #[async_trait::async_trait] + impl CacheManager for CaCacheManager { + async fn put( + &self, + cache_key: String, + response: HttpResponse, + policy: CachePolicy, + ) -> Result { + let data = Store { response: response.clone(), policy }; + let bytes = bincode::serialize(&data)?; + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&bytes)?; + let compressed_bytes = encoder.finish()?; + + cacache::write(&self.path, cache_key, compressed_bytes).await?; + Ok(response) + } + + async fn get(&self, cache_key: &str) -> Result> { + match cacache::read(&self.path, cache_key).await { + Ok(compressed_data) => { + let mut decoder = flate2::read::GzDecoder::new(compressed_data.as_slice()); + let mut serialized_data = Vec::new(); + decoder.read_to_end(&mut serialized_data)?; + let store: Store = bincode::deserialize(&serialized_data)?; + Ok(Some((store.response, store.policy))) + } + Err(_) => Ok(None), + } + } + + async fn delete(&self, cache_key: &str) -> Result<()> { + Ok(cacache::remove(&self.path, cache_key).await?) + } + } +} + +pub mod file { + use std::collections::HashMap; + use std::sync::Arc; + + use async_trait::async_trait; + use tailcall::core::FileIO; + use tokio::sync::RwLock; + + #[derive(Clone, Default)] + pub struct NativeFileTest(Arc>>); + #[async_trait] + impl FileIO for NativeFileTest { + async fn write<'a>(&'a self, path: &'a str, content: &'a [u8]) -> anyhow::Result<()> { + self.0.write().await.insert( + path.to_string(), + String::from_utf8_lossy(content).to_string(), + ); + Ok(()) + } + + async fn read<'a>(&'a self, path: &'a str) -> anyhow::Result { + let val = if let Some(val) = self.0.read().await.get(path).cloned() { + val + } else { + std::fs::read_to_string(path)? + }; + Ok(val) + } + } +} + +pub mod http { + use anyhow::Result; + use http_cache_reqwest::{Cache, CacheMode, HttpCache, HttpCacheOptions}; + use hyper::body::Bytes; + use reqwest::Client; + use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; + use tailcall::core::http::Response; + use tailcall::core::HttpIO; + + use super::cacache_manager::CaCacheManager; + + #[derive(Clone)] + pub struct NativeHttpTest { + client: ClientWithMiddleware, + } + + impl Default for NativeHttpTest { + fn default() -> Self { + let mut client = ClientBuilder::new(Client::new()); + client = client.with(Cache(HttpCache { + mode: CacheMode::ForceCache, + manager: CaCacheManager::default(), + options: HttpCacheOptions::default(), + })); + Self { client: client.build() } + } + } + + #[async_trait::async_trait] + impl HttpIO for NativeHttpTest { + #[allow(clippy::blocks_in_conditions)] + async fn execute(&self, request: reqwest::Request) -> Result> { + let response = self.client.execute(request).await; + Ok(Response::from_reqwest( + response? + .error_for_status() + .map_err(|err| err.without_url())?, + ) + .await?) + } + } +} +pub mod env { + use std::borrow::Cow; + use std::collections::HashMap; + + use tailcall::core::EnvIO; + + #[derive(Clone)] + pub struct Env(pub HashMap); + + impl EnvIO for Env { + fn get(&self, key: &str) -> Option> { + self.0.get(key).map(Cow::from) + } + } +} + pub mod test { use std::path::Path; + use crate::parser::ExecutionSpec; + mod cacache_manager { use std::io::{Read, Write}; use std::path::PathBuf; @@ -120,25 +280,31 @@ pub mod test { use tailcall::core::config::{self, ConfigModule}; use tailcall::core::generator::Generator as ConfigGenerator; use tailcall::core::valid::{ValidateInto, Validator}; - use tokio::runtime::Runtime; use super::http::NativeHttpTest; + use crate::env::Env; + use crate::parser::{ExecutionSpec, IO}; - pub fn run_config_generator_spec(path: &Path) -> datatest_stable::Result<()> { - let path = path.to_path_buf(); - let runtime = Runtime::new().unwrap(); - runtime.block_on(async move { - run_test(&path.to_string_lossy()).await?; - Ok(()) - }) - } + pub async fn run_test(original_path: &Path, spec: ExecutionSpec) -> anyhow::Result<()> { + let snapshot_name = original_path.to_string_lossy().to_string(); + + let IO { fs, paths } = spec.configs.into_io().await; + let path = paths.first().unwrap().as_str(); - async fn run_test(path: &str) -> anyhow::Result<()> { let mut runtime = tailcall::cli::runtime::init(&Blueprint::default()); runtime.http = Arc::new(NativeHttpTest::default()); + runtime.file = Arc::new(fs); + if let Some(env) = spec.env { + runtime.env = Arc::new(Env(env)) + } let generator = Generator::new(path, runtime); let config = generator.read().await?; + if spec.debug_assert_config { + insta::assert_debug_snapshot!(snapshot_name, config); + return Ok(()); + } + let query_type = config.schema.query.clone().unwrap_or("Query".into()); let mutation_type_name = config.schema.mutation.clone(); let preset: config::transformer::Preset = config @@ -164,11 +330,11 @@ pub mod test { let config = ConfigModule::from(base_config); - insta::assert_snapshot!(path, config.to_sdl()); + insta::assert_snapshot!(snapshot_name, config.to_sdl()); Ok(()) } } - pub fn test_generator(path: &Path) -> datatest_stable::Result<()> { + async fn test_generator(path: &Path) -> datatest_stable::Result<()> { if let Some(extension) = path.extension() { if extension == "json" && path @@ -177,15 +343,15 @@ pub mod test { .map(|v| v.starts_with("gen")) .unwrap_or_default() { - let _ = generator_spec::run_config_generator_spec(path); + let spec = ExecutionSpec::from_source(path, std::fs::read_to_string(path)?)?; + generator_spec::run_test(path, spec).await?; } } Ok(()) } + pub fn run(path: &Path) -> datatest_stable::Result<()> { + tokio_test::block_on(test_generator(path)) + } } -datatest_stable::harness!( - test::test_generator, - "tests/cli/fixtures/generator", - r"^.*\.json" -); +datatest_stable::harness!(test::run, "tests/cli/fixtures/generator", r"^.*\.md"); diff --git a/tests/cli/parser.rs b/tests/cli/parser.rs new file mode 100644 index 0000000000..32d826f953 --- /dev/null +++ b/tests/cli/parser.rs @@ -0,0 +1,132 @@ +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; + +use anyhow::anyhow; +use markdown::mdast::Node; +use markdown::ParseOptions; +use tailcall::core::config::Source; +use tailcall::core::FileIO; + +use crate::file::NativeFileTest; + +#[derive(Clone)] +pub struct ExecutionSpec { + pub env: Option>, + pub configs: ConfigHolder, + + // if this is set to true, + // then we will assert Config + // instead of asserting the generated config + pub debug_assert_config: bool, +} + +pub struct IO { + pub fs: NativeFileTest, + pub paths: Vec, +} + +#[derive(Clone)] +pub struct ConfigHolder { + configs: Vec<(Source, String)>, +} + +impl ConfigHolder { + pub async fn into_io(self) -> IO { + let fs = NativeFileTest::default(); + let mut paths = vec![]; + for (i, (source, content)) in self.configs.iter().enumerate() { + let path = format!("config{}.{}", i, source.ext()); + fs.write(&path, content.as_bytes()).await.unwrap(); + paths.push(path); + } + IO { fs, paths } + } +} + +impl ExecutionSpec { + pub fn from_source(path: &Path, contents: String) -> anyhow::Result { + let ast = markdown::to_mdast(&contents, &ParseOptions::default()).unwrap(); + let children = ast + .children() + .unwrap_or_else(|| panic!("Failed to parse {:?}: empty file unexpected", path)) + .iter() + .peekable(); + + let mut env = None; + let mut debug_assert_config = false; + let mut configs = vec![]; + + for node in children { + match node { + Node::Heading(heading) => { + if heading.depth == 2 { + if let Some(Node::Text(expect)) = heading.children.first() { + let split = expect.value.splitn(2, ':').collect::>(); + match split[..] { + [a, b] => { + debug_assert_config = + a.contains("debug_assert") && b.ends_with("true"); + } + _ => { + return Err(anyhow!( + "Unexpected header annotation {:?} in {:?}", + expect.value, + path, + )) + } + } + } + } + } + Node::Code(code) => { + let (content, lang, meta) = { + ( + code.value.to_owned(), + code.lang.to_owned(), + code.meta.to_owned(), + ) + }; + if let Some(meta_str) = meta.as_ref().filter(|s| s.contains('@')) { + let temp_cleaned_meta = meta_str.replace('@', ""); + let name: &str = &temp_cleaned_meta; + + let lang = match lang { + Some(x) => Ok(x), + None => Err(anyhow!( + "Unexpected code block with no specific language in {:?}", + path + )), + }?; + let source = Source::from_str(&lang)?; + match name { + "config" => { + configs.push((source, content)); + } + "env" => { + let vars: HashMap = match source { + Source::Json => Ok(serde_json::from_str(&content)?), + Source::Yml => Ok(serde_yaml::from_str(&content)?), + _ => Err(anyhow!("Unexpected language in env block in {:?} (only JSON and YAML are supported)", path)), + }?; + + env = Some(vars); + } + _ => { + return Err(anyhow!( + "Unexpected component {:?} in {:?}: {:#?}", + name, + path, + meta + )); + } + } + } + } + _ => return Err(anyhow!("Unexpected node in {:?}: {:#?}", path, node)), + } + } + + Ok(Self { env, configs: ConfigHolder { configs }, debug_assert_config }) + } +} From ecf07e43ff6f0bc2c177b56e6b4ec4187efb28e6 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod Date: Mon, 26 Aug 2024 15:58:54 -0400 Subject: [PATCH 2/4] fix tests --- src/cli/generator/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/generator/config.rs b/src/cli/generator/config.rs index 5bdf9d6d2d..dd21738046 100644 --- a/src/cli/generator/config.rs +++ b/src/cli/generator/config.rs @@ -436,7 +436,7 @@ mod tests { fn test_raise_error_unknown_field_at_root_level() { let json = r#"{"input": "value"}"#; let expected_error = - "unknown field `input`, expected one of `inputs`, `output`, `preset`, `schema`, `llm` at line 1 column 8"; + "unknown field `input`, expected one of `inputs`, `output`, `preset`, `schema`, `secret`, `llm` at line 1 column 8"; assert_deserialization_error(json, expected_error); } From 14f14ef4006ccb862f86dfda2103b2ef6eb6dfcd Mon Sep 17 00:00:00 2001 From: Tushar Mathur Date: Tue, 27 Aug 2024 16:58:00 +0530 Subject: [PATCH 3/4] drop secret --- src/cli/generator/config.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cli/generator/config.rs b/src/cli/generator/config.rs index dd21738046..66e41a83ca 100644 --- a/src/cli/generator/config.rs +++ b/src/cli/generator/config.rs @@ -25,8 +25,6 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub preset: Option, pub schema: Schema, - #[serde(default, skip_serializing_if = "TemplateString::is_empty")] - pub secret: TemplateString, #[serde(skip_serializing_if = "Option::is_none")] pub llm: Option, } @@ -295,7 +293,6 @@ impl Config { output, schema: self.schema, preset: self.preset, - secret: self.secret, llm, }) } From 81b26f5d412bd2e4016210b0193a4052f3175788 Mon Sep 17 00:00:00 2001 From: Tushar Mathur Date: Tue, 27 Aug 2024 18:11:57 +0530 Subject: [PATCH 4/4] update test --- src/cli/generator/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/generator/config.rs b/src/cli/generator/config.rs index 66e41a83ca..dab568cb42 100644 --- a/src/cli/generator/config.rs +++ b/src/cli/generator/config.rs @@ -433,7 +433,7 @@ mod tests { fn test_raise_error_unknown_field_at_root_level() { let json = r#"{"input": "value"}"#; let expected_error = - "unknown field `input`, expected one of `inputs`, `output`, `preset`, `schema`, `secret`, `llm` at line 1 column 8"; + "unknown field `input`, expected one of `inputs`, `output`, `preset`, `schema`, `llm` at line 1 column 8"; assert_deserialization_error(json, expected_error); }