diff --git a/Cargo.lock b/Cargo.lock index ba003df..1a8c3c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,8 @@ dependencies = [ "hyper-util", "indexmap", "jsonwebtoken", + "lazy_static", + "minijinja", "notify", "oas3", "redis", @@ -1551,6 +1553,12 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "mime" version = "0.3.17" @@ -1567,6 +1575,17 @@ dependencies = [ "unicase", ] +[[package]] +name = "minijinja" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55e877d961d4f96ce13615862322df7c0b6d169d40cab71a7ef3f9b9e594451e" +dependencies = [ + "memo-map", + "self_cell", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2456,6 +2475,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" + [[package]] name = "semver" version = "1.0.24" diff --git a/aiscript-runtime/Cargo.toml b/aiscript-runtime/Cargo.toml index 8b85e39..0792eeb 100644 --- a/aiscript-runtime/Cargo.toml +++ b/aiscript-runtime/Cargo.toml @@ -35,3 +35,5 @@ redis.workspace = true toml = "0.8" oas3 = "0.15" reqwest.workspace = true +minijinja = { version = "1.0", features = ["loader"] } +lazy_static = "1.4" diff --git a/aiscript-runtime/src/endpoint.rs b/aiscript-runtime/src/endpoint.rs index 5520e30..3ed8bbe 100644 --- a/aiscript-runtime/src/endpoint.rs +++ b/aiscript-runtime/src/endpoint.rs @@ -1,3 +1,4 @@ +use crate::template; use aiscript_directive::{Validator, route::RouteAnnotation}; use aiscript_vm::{ReturnValue, Vm, VmError}; use axum::{ @@ -607,3 +608,8 @@ pub(crate) fn convert_field(field: ast::Field) -> Field { validators: Arc::from(field.validators), } } + +pub fn render(template: &str, context: serde_json::Value) -> Result { + let engine = template::get_template_engine(); + engine.render(template, &context) +} diff --git a/aiscript-runtime/src/lib.rs b/aiscript-runtime/src/lib.rs index 0c188db..554d49f 100644 --- a/aiscript-runtime/src/lib.rs +++ b/aiscript-runtime/src/lib.rs @@ -23,6 +23,7 @@ mod endpoint; mod error; mod openapi; mod parser; +mod template; mod utils; use aiscript_lexer as lexer; @@ -300,3 +301,56 @@ async fn run_server( } } } + +// Generate OpenAPI JSON from routes +fn generate_openapi_json(routes: &[ast::Route]) -> serde_json::Value { + let mut openapi = serde_json::json!({ + "openapi": "3.0.0", + "info": { + "title": "AIScript API", + "version": "1.0.0", + "description": "API documentation for AIScript" + }, + "paths": {}, + }); + + //Add paths from routes + let paths = openapi["paths"].as_object_mut().unwrap(); + + for route in routes { + for endpoint in &route.endpoints { + for path_spec in &endpoint.path_specs { + let path = if route.prefix == "/" { + path_spec.path.clone() + } else { + format!("{}{}", route.prefix, path_spec.path) + }; + + let method = match path_spec.method { + ast::HttpMethod::Get => "get", + ast::HttpMethod::Post => "post", + ast::HttpMethod::Put => "put", + ast::HttpMethod::Delete => "delete", + }; + + //For each method, add the path and method to the paths object + if !paths.contains_key(&path) { + paths.insert(path.clone(), serde_json::json!({})); + } + + //Add the method to the path + let path_obj = paths.get_mut(&path).unwrap(); + path_obj[method] = serde_json::json!({ + "summary": format!("{} {}", method.to_uppercase(), path), + "responses": { + "200": { + "description": "Successful response" + } + } + }); + } + } + } + + openapi +} diff --git a/aiscript-runtime/src/template.rs b/aiscript-runtime/src/template.rs new file mode 100644 index 0000000..1a5f077 --- /dev/null +++ b/aiscript-runtime/src/template.rs @@ -0,0 +1,72 @@ +use minijinja::Environment; +use std::sync::RwLock; + +/// Template engine for AIScript +pub struct TemplateEngine { + env: RwLock>, +} + +impl TemplateEngine { + /// Create a new template engine + pub fn new() -> Self { + let mut env = Environment::new(); + + //Set the source to the templates directory + env.set_loader(|name| -> Result, minijinja::Error> { + let path = std::path::Path::new("templates").join(name); + match std::fs::read_to_string(path) { + Ok(content) => Ok(Some(content)), + Err(_) => Ok(None), + } + }); + + Self { + env: RwLock::new(env), + } + } + + /// Render a template with the given context + pub fn render( + &self, + template_name: &str, + context: &serde_json::Value, + ) -> Result { + let env = self.env.read().unwrap(); + + // get the template + let template = env + .get_template(template_name) + .map_err(|e| format!("Failed to load template '{}': {}", template_name, e))?; + + // render the template and return the result + template + .render(context) + .map_err(|e| format!("Failed to render template '{}': {}", template_name, e)) + } + + /// Reload the templates + pub fn reload(&self) -> Result<(), String> { + let mut env = self.env.write().unwrap(); + + //reload templates + env.set_loader(|name| -> Result, minijinja::Error> { + let path = std::path::Path::new("templates").join(name); + match std::fs::read_to_string(path) { + Ok(content) => Ok(Some(content)), + Err(_) => Ok(None), + } + }); + + Ok(()) + } +} + +//Create a global instance of the template engine +lazy_static::lazy_static! { + static ref TEMPLATE_ENGINE: TemplateEngine = TemplateEngine::new(); +} + +//Get the template engine instance +pub fn get_template_engine() -> &'static TemplateEngine { + &TEMPLATE_ENGINE +} diff --git a/aiscript-vm/src/stdlib/web.rs b/aiscript-vm/src/stdlib/web.rs new file mode 100644 index 0000000..2f7fed8 --- /dev/null +++ b/aiscript-vm/src/stdlib/web.rs @@ -0,0 +1,22 @@ +use crate::value::Value; +use aiscript_runtime::template::get_template_engine; + +/// Render a template with the given context +pub fn render(args: &[Value]) -> Result { + if args.len() != 2 { + return Err("render expects 2 arguments: template name and context".to_string()); + } + + //get the template name + let template_name = args[0].as_str().ok_or_else(|| "First argument must be a string (template name)".to_string())?; + + //get the context + let context = serde_json::to_value(&args[1]) + .map_err(|e| format!("Failed to convert context to JSON: {}", e))?; + + //render the template + let result = get_template_engine().render(template_name, context)?; + + OK(Value::String(result.into())) + +} \ No newline at end of file diff --git a/routes/template-test.ai b/routes/template-test.ai new file mode 100644 index 0000000..2ff7501 --- /dev/null +++ b/routes/template-test.ai @@ -0,0 +1,7 @@ +route /template { + get /hello { + name: str, + is_admin: bool = false, + } + return render("hello-page.jinja", query) +} \ No newline at end of file diff --git a/templates/hello-page.jinja b/templates/hello-page.jinja new file mode 100644 index 0000000..7df5c26 --- /dev/null +++ b/templates/hello-page.jinja @@ -0,0 +1,5 @@ +{% if query.is_admin %} +
Hello, {{ query.name }} (Admin)
+{% else %} +
Hello, {{ query.name }}
+{% endif %} \ No newline at end of file