danirod / cartero

Make HTTP requests and test APIs
https://cartero.danirod.es
GNU General Public License v3.0
419 stars 31 forks source link

Export as code (axios, fetch, Go net/http...) #64

Open danirod opened 3 months ago

AlphaTechnolog commented 3 weeks ago

I think as this could also be implemented with the changes proposed in #86 i think i'd like to take this one as well.

danirod commented 3 weeks ago

Oops, sorry. Let me do that

AlphaTechnolog commented 2 weeks ago

Hi! I'd like to discuss about some refactors i've been thinking about this feature... well, the thing is that to export for others languages, in the way the export code service is working right now, one would have to manually build the string using rust code to do the loops of headers, etc, with conditionals for proper formatting on code generation... This could be kinda manual and tedious considering we're gonna aim to have more and more formats to export such as the ones in the title of this issue.

I'd like to propose the usage of some kind of compile time template engine processor such as askama which i tried recently and seems to work pretty good, in fact, i've got to generate in a sample project a curl request string based on a template and a structure with the data. Here's the code snippet I used:

templates/curl

{%- macro backslash(headers, index) %}
{%- if index < headers.len() - 1 %} \{%- endif %}
{%- endmacro -%}

curl -X {{ method }} {{ url }}{% if let Some(headers) = headers %} \
{%- for header in headers %}
  -H '{{ header.key }}: {{ header.value }}'{% call backslash(headers, loop.index0) -%}
{%- endfor %}
{%- endif %}{% if let Some(body) = body %} \
{%- if let Some(fmtted) = self::format_body_curl(body) %}
  -d '{{ fmtted }}'
{%- endif %}
{%- endif %}

As you can see, one can create macros, loops, conditionals, formatting, even use rust expressions inside conditions loops and others, also allows calling rust functions (self::format_body_curl()). About those - inside the {% endif %} and others expressions, they're to remove the trailing lines those expressions generates, else we would have a bunch of newlines in the generated output. As far as I understood if i read correctly the askama documentation, this will generate some kind of formatter in compile time and then convert it to String in runtime when one calls .render from rust.

src/main.rs

#![feature(iter_intersperse)]
use askama::Template;
use std::{collections::HashMap, convert::From};

struct HttpHeader {
    key: String,
    value: String,
}

impl<'a> From<(&'a str, &'a str)> for HttpHeader {
    fn from(value: (&'a str, &'a str)) -> HttpHeader {
        HttpHeader {
            key: value.0.into(),
            value: value.1.into(),
        }
    }
}

#[derive(Clone, Copy)]
enum RawEncoding {
    Json,
    Raw,
}

#[derive(Clone)]
enum Body {
    UrlEncoded(HashMap<String, String>),
    Raw {
        encoding: RawEncoding,
        contents: String,
    }
}

fn format_body_urlencoded(urlencoded: &HashMap<String, String>) -> String {
    let mut value: Vec<String> = Vec::new();

    for (key, val) in urlencoded.iter() {
        value.push(format!("{key}={val}"));
    }

    value.join("&")
}

impl From<Body> for String {
    fn from(value: Body) -> Self {
        match value {
            Body::UrlEncoded(hashmap) => {
                format_body_urlencoded(&hashmap)
            },
            Body::Raw {
                encoding: _encoding,
                contents,
            } => contents,
        }
    }
}

/// Formats the body for the curl output.
fn format_body_curl(body: &&Body) -> Option<String> {
    let body = *body;
    let body = body.clone();
    Some(body.into())
}

#[derive(Template)]
#[template(path = "curl")]
struct CurlTemplate {
    url: String,
    method: String,
    headers: Option<Vec<HttpHeader>>,
    body: Option<Body>,
}

fn main() {
    let example_1 = CurlTemplate {
        url: "https://jsonplaceholder.typicode.com/users".into(),
        method: "GET".into(),
        headers: Some(vec![
            ("Content-Type", "application/json").into(),
            ("Accept", "application/json").into(),
        ]),
        body: Some(Body::Raw {
            encoding: RawEncoding::Json,
            contents: "{\"name\": \"Arch\"}".into(),
        }),
    };

    println!("\nExample 1\n");

    if let Ok(rendered) = example_1.render() {
        println!("{}", rendered.trim());
    }

    let example_2 = CurlTemplate {
        url: "https://jsonplaceholder.typicode.com/todos/1".into(),
        method: "GET".into(),
        headers: None,
        body: Some(Body::UrlEncoded({
            let data: Vec<(String, String)> = vec![
                ("example".into(), "key".into()),
                ("another".into(), "one".into()),
            ];

            data.into_iter().collect::<HashMap<String, String>>()
        }))
    };

    println!("\nExample 2\n");

    if let Ok(rendered) = example_2.render() {
        println!("{}", rendered.trim());
    }
}

Sorry for the quite large example, just wanted to test having both headers and body in a similar structure like the BoundRequest one, we would just have to maybe convert some types but that's kinda trivial.

Lmk your comments, do you think this could help us to build the others formats? Or in the opposite, there's some other option could help us more or just build the Strings manually.

AlphaTechnolog commented 2 weeks ago

Here's a patch which integrates askama in the current service we're using to generate curl code, this will make cartero export the request to curl using askama templates instead, just in case you wanna see it working in the app itself.

diff --git a/Cargo.lock b/Cargo.lock
index 312e0ba..3553fc5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4

 [[package]]
 name = "addr2line"
@@ -32,6 +32,50 @@ version = "1.0.81"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"

+[[package]]
+name = "askama"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
+dependencies = [
+ "askama_derive",
+ "askama_escape",
+ "humansize",
+ "num-traits",
+ "percent-encoding 2.3.1",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "mime 0.3.17",
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.53",
+]
+
+[[package]]
+name = "askama_escape"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
+
+[[package]]
+name = "askama_parser"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
+dependencies = [
+ "nom",
+]
+
 [[package]]
 name = "async-channel"
 version = "1.9.0"
@@ -80,6 +124,15 @@ version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"

+[[package]]
+name = "basic-toml"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -144,6 +197,7 @@ dependencies = [
 name = "cartero"
 version = "0.2.0"
 dependencies = [
+ "askama",
  "formdata",
  "futures-lite 2.3.0",
  "gettext-rs",
@@ -814,6 +868,15 @@ version = "1.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"

+[[package]]
+name = "humansize"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
+dependencies = [
+ "libm",
+]
+
 [[package]]
 name = "hyper"
 version = "0.10.16"
@@ -829,7 +892,7 @@ dependencies = [
  "time",
  "traitobject",
  "typeable",
- "unicase",
+ "unicase 1.4.2",
  "url 1.7.2",
 ]

@@ -956,6 +1019,12 @@ version = "0.2.153"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"

+[[package]]
+name = "libm"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
+
 [[package]]
 name = "libnghttp2-sys"
 version = "0.1.9+1.58.0"
@@ -1067,6 +1136,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"

+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime 0.3.17",
+ "unicase 2.8.0",
+]
+
 [[package]]
 name = "mime_multipart"
 version = "0.6.1"
@@ -1108,6 +1187,15 @@ dependencies = [
  "minimal-lexical",
 ]

+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "num_cpus"
 version = "1.16.0"
@@ -1860,6 +1948,12 @@ dependencies = [
  "version_check 0.1.5",
 ]

+[[package]]
+name = "unicase"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df"
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.15"
diff --git a/Cargo.toml b/Cargo.toml
index 9f983d8..06868c1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ csd = []

 [dependencies]
 adw = { version = "0.6.0", package = "libadwaita", features = ["v1_5", "gtk_v4_12"] }
+askama = "0.12.1"
 formdata = "0.13.0"
 futures-lite = "2.3.0"
 gettext-rs = { version = "0.7.0", features = ["gettext-system"] }
diff --git a/src/error.rs b/src/error.rs
index 4489e22..fbee291 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -34,4 +34,7 @@ pub enum CarteroError {

     #[error("Outdated schema, please update the software")]
     OutdatedSchema,
+
+    #[error("Template generation error")]
+    AskamaFailed,
 }
diff --git a/src/widgets/export_tab/code.rs b/src/widgets/export_tab/code.rs
index cd0d270..8fda36e 100644
--- a/src/widgets/export_tab/code.rs
+++ b/src/widgets/export_tab/code.rs
@@ -266,7 +266,7 @@ impl BaseExportPaneExt for CodeExportPane {
             let service = CodeExportService::new(data.clone());
             let imp = self.imp();

-            if let Ok(command) = service.generate() {
+            if let Ok(command) = service.into_curl_like() {
                 imp.set_buffer_content(command.as_bytes());
             }
         }
diff --git a/src/widgets/export_tab/service.rs b/src/widgets/export_tab/service.rs
index 7e2f6b4..897ae54 100644
--- a/src/widgets/export_tab/service.rs
+++ b/src/widgets/export_tab/service.rs
@@ -15,12 +15,52 @@
 //
 // SPDX-License-Identifier: GPL-3.0-or-later

-use serde_json::{Error, Value};
+use askama::Template;

 use crate::client::BoundRequest;
-use crate::entities::{EndpointData, RequestPayload};
+use crate::entities::EndpointData;
 use crate::error::CarteroError;

+mod templates {
+    use crate::client::BoundRequest;
+    use askama::Template;
+    use serde_json::Value;
+    use std::convert::From;
+
+    #[macro_export]
+    macro_rules! generate_template_struct {
+        ($struct_name:ident, $template_path:expr) => {
+            #[derive(Template)]
+            #[template(path = $template_path)]
+            pub struct $struct_name {
+                pub url: String,
+                pub method: String,
+                pub headers: std::collections::HashMap<String, String>,
+                pub body: Option<String>,
+            }
+
+            impl From<BoundRequest> for $struct_name {
+                fn from(value: BoundRequest) -> Self {
+                    Self {
+                        url: value.url,
+                        method: value.method.into(),
+                        headers: value.headers,
+                        body: value.body.map(|v| {
+                            let body = String::from_utf8_lossy(&v).to_string();
+
+                            serde_json::from_str(body.as_ref()).map_or(body, |v: Value| {
+                                serde_json::to_string(&v).unwrap().replace("'", "\\\\'")
+                            })
+                        }),
+                    }
+                }
+            }
+        };
+    }
+
+    generate_template_struct!(CurlTemplate, "curl");
+}
+
 pub struct CodeExportService {
     endpoint_data: EndpointData,
 }
@@ -30,71 +70,10 @@ impl CodeExportService {
         Self { endpoint_data }
     }

-    pub fn generate(&self) -> Result<String, CarteroError> {
+    pub fn into_curl_like(&self) -> Result<String, CarteroError> {
         let bound_request = BoundRequest::try_from(self.endpoint_data.clone())?;
-        let mut command = "curl".to_string();
-
-        command.push_str(&{
-            let method_str: String = bound_request.method.into();
-            format!(" -X {} '{}'", method_str, bound_request.url)
-        });
-
-        if !bound_request.headers.is_empty() {
-            let size = bound_request.headers.len();
-            let mut keys: Vec<&String> = bound_request.headers.keys().collect();
-            keys.sort();
-
-            command.push_str(" \\\n");
-
-            for (i, key) in keys.iter().enumerate() {
-                let val = bound_request.headers.get(*key).unwrap();
-
-                command.push_str(&{
-                    let mut initial = format!("  -H '{key}: {val}'");
-
-                    if i < size - 1 {
-                        initial.push_str(" \\\n");
-                    }
-
-                    initial
-                });
-            }
-        }
-
-        if let RequestPayload::Urlencoded(_) = &self.endpoint_data.body {
-            if let Some(bd) = bound_request.body {
-                let str = String::from_utf8_lossy(&bd).to_string();
-                command.push_str(&format!(" \\\n  -d '{str}'"));
-            }
-        }
-
-        if let RequestPayload::Raw {
-            encoding: _,
-            content,
-        } = &self.endpoint_data.body
-        {
-            command.push_str(&'fmt: {
-                let body = String::from_utf8_lossy(content).to_string();
-                let value: Result<Value, Error> = serde_json::from_str(body.as_ref());
-
-                if value.is_err() {
-                    break 'fmt String::new();
-                }
-
-                let value = value.unwrap();
-                let trimmed_json_str = serde_json::to_string(&value);
-
-                if trimmed_json_str.is_err() {
-                    break 'fmt String::new();
-                }
-
-                let trimmed_json_str = trimmed_json_str.unwrap();
-                let trimmed_json_str = trimmed_json_str.replace("'", "\\\\'");
-
-                format!(" \\\n  -d '{}'", trimmed_json_str)
-            });
-        }
+        let template: templates::CurlTemplate = bound_request.into();

-        Ok(command)
+        template.render().map_err(|_| CarteroError::AskamaFailed)
     }
 }
diff --git a/templates/curl b/templates/curl
new file mode 100644
index 0000000..93eb988
--- /dev/null
+++ b/templates/curl
@@ -0,0 +1,11 @@
+{%- macro backslash(headers, index) %}
+{%- if index < headers.len() - 1 %} \{%- endif %}
+{%- endmacro -%}
+
+curl -X {{ method }} '{{ url }}'{% if !headers.is_empty() %} \
+{%- for (key, value) in headers.iter() %}
+  -H '{{ key }}: {{ value }}'{% call backslash(headers, loop.index0) -%}
+{%- endfor %}
+{%- endif %}{% if let Some(body) = body %} \
+  -d '{{ body }}'
+{%- endif %}
\ No newline at end of file