Просмотр исходного кода

improve url and add request sending

biblius 3 недель назад
Родитель
Сommit
0b172035e5

+ 1 - 1
src-tauri/migrations/20250922150745_init.up.sql

@@ -43,10 +43,10 @@ CREATE TABLE request_params (
 );
 
 CREATE TABLE request_path_params (
+    position INTEGER PRIMARY KEY NOT NULL,
     request_id INTEGER NOT NULL,
     name TEXT NOT NULL,
     value TEXT NOT NULL,
-    UNIQUE(request_id, name),
     FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 

+ 90 - 12
src-tauri/src/cmd.rs

@@ -1,12 +1,11 @@
 use std::collections::HashMap;
 
-use tauri_plugin_log::log;
-
 use crate::{
     db,
     request::{
+        self,
         url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
-        RequestPathUpdate,
+        HttpRequestParameters, HttpResponse, RequestPathParam,
     },
     state::AppState,
     var::{expand_vars, parse_vars},
@@ -15,6 +14,7 @@ use crate::{
         WorkspaceEntryUpdateBase, WorkspaceEnvVariable, WorkspaceEnvironment,
     },
 };
+use tauri_plugin_log::log;
 
 #[tauri::command]
 pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
@@ -173,7 +173,7 @@ pub async fn update_url(
     env_id: Option<i64>,
     url: String,
     path_params: HashMap<String, String>,
-) -> Result<RequestUrlOwned, UrlError> {
+) -> Result<(RequestUrlOwned, Vec<RequestPathParam>), UrlError> {
     let url_expanded = if let Some(env_id) = env_id {
         let vars = match parse_vars(&url) {
             Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
@@ -192,14 +192,12 @@ pub async fn update_url(
 
     match RequestUrl::parse(url_expanded.as_ref().unwrap_or(&url)) {
         Ok(url_parsed) => {
-            let mut insert: Vec<RequestPathUpdate> = vec![];
+            let mut insert: Vec<RequestPathParam> = vec![];
 
             for seg in url_parsed.path.iter() {
-                if let Segment::Dynamic(seg) = seg {
-                    if insert.iter().find(|i| i.name == *seg).is_some() {
-                        return Err(UrlError::DuplicatePath(seg.to_string()));
-                    }
-                    insert.push(RequestPathUpdate {
+                if let Segment::Dynamic(seg, position) = seg {
+                    insert.push(RequestPathParam {
+                        position: *position as i64,
                         name: seg.to_string(),
                         value: path_params.get(*seg).cloned().unwrap_or_default(),
                     })
@@ -210,7 +208,7 @@ pub async fn update_url(
                 state.db.clone(),
                 entry_id,
                 WorkspaceEntryUpdate::Request {
-                    path_params: Some(insert),
+                    path_params: Some(insert.clone()),
                     url: Some(url.clone()),
                     base: WorkspaceEntryUpdateBase::default(),
                     body: None,
@@ -221,7 +219,12 @@ pub async fn update_url(
             .await
             .map_err(|e| UrlError::Db(e.to_string()))?;
 
-            Ok(url_parsed.into())
+            let params = match db::list_request_path_params(state.db.clone(), entry_id).await {
+                Ok(p) => p,
+                Err(e) => return Err(UrlError::Db(e.to_string())),
+            };
+
+            Ok((url_parsed.into(), params))
         }
         Err(e) => {
             log::debug!("{e:?}");
@@ -230,6 +233,81 @@ pub async fn update_url(
     }
 }
 
+#[tauri::command]
+pub async fn send_request(
+    state: tauri::State<'_, AppState>,
+    req_id: i64,
+    env_id: Option<i64>,
+) -> Result<HttpResponse, String> {
+    let mut req = match db::get_workspace_request(state.db.clone(), req_id).await {
+        Ok(req) => req,
+        Err(e) => return Err(e.to_string()),
+    };
+
+    req.url = if let Some(env_id) = env_id {
+        let vars = match parse_vars(&req.url) {
+            Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
+            Err(e) => return Err(e.to_string()),
+        };
+
+        let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+            Ok(v) => v,
+            Err(e) => return Err(e.to_string()),
+        };
+
+        expand_vars(&req.url, &vars)
+    } else {
+        req.url
+    };
+
+    let req = match RequestUrl::parse(&req.url.to_string()) {
+        Ok(mut url) => {
+            let params = match db::list_request_path_params(state.db.clone(), req_id).await {
+                Ok(p) => p,
+                Err(e) => return Err(e.to_string()),
+            };
+
+            url.populate(
+                params
+                    .iter()
+                    .map(|p| (p.name.as_str(), p.value.as_str()))
+                    .collect(),
+            );
+
+            req.url = url.to_string();
+
+            let vars = match parse_vars(&req.url) {
+                Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
+                Err(e) => return Err(e.to_string()),
+            };
+
+            req.url = if let Some(env_id) = env_id {
+                let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+                    Ok(v) => v,
+                    Err(e) => return Err(e.to_string()),
+                };
+
+                expand_vars(&url.to_string(), &vars)
+            } else {
+                req.url
+            };
+
+            HttpRequestParameters::try_from(req)?
+        }
+        Err(e) => {
+            log::debug!("{e:?}");
+            return Err(String::from("error parsing URL"));
+        }
+    };
+
+    let response = match request::send(state.client.clone(), req).await {
+        Ok(res) => res,
+        Err(e) => return Err(e.to_string()),
+    };
+
+    Ok(response)
+}
+
 #[tauri::command]
 pub async fn list_environments(
     state: tauri::State<'_, AppState>,

+ 77 - 20
src-tauri/src/db.rs

@@ -40,15 +40,6 @@ impl<T: Copy> Update<T> {
     }
 }
 
-// impl<T> Update<T> {
-//     pub fn value_ref(&self) -> Option<&T> {
-//         match self {
-//             Update::Value(v) => Some(v),
-//             Update::Null => None,
-//         }
-//     }
-// }
-
 pub async fn init(url: &str) -> SqlitePool {
     let pool = SqlitePool::connect(url)
         .await
@@ -357,18 +348,38 @@ pub async fn update_workspace_entry(
 
             if let Some(path_params) = path_params {
                 if !path_params.is_empty() {
-                    let mut sql =
-                        QueryBuilder::new("DELETE FROM request_path_params WHERE request_id = ");
-
-                    sql.push_bind(entry_id)
-                        .push("; INSERT INTO request_path_params(request_id, name, value) ");
+                    let mut sql = QueryBuilder::new(
+                        "INSERT INTO request_path_params(position, request_id, name, value) ",
+                    );
 
-                    sql.push_values(path_params, |mut b, path| {
-                        b.push_bind(entry_id)
-                            .push_bind(path.name)
-                            .push_bind(path.value);
+                    sql.push_values(path_params.iter(), |mut b, path| {
+                        b.push_bind(path.position)
+                            .push_bind(entry_id)
+                            .push_bind(&path.name)
+                            .push_bind(&path.value);
                     });
 
+                    sql.push(
+                        r#"
+                        ON CONFLICT(position) DO UPDATE 
+                        SET 
+                            value = COALESCE(NULLIF(excluded.value, ''), value),
+                            name = excluded.name;
+
+                        DELETE FROM request_path_params 
+                        WHERE request_id = "#,
+                    )
+                    .push_bind(entry_id)
+                    .push(" AND position NOT IN (");
+
+                    let mut sep = sql.separated(", ");
+
+                    for param in path_params.iter() {
+                        sep.push_bind(param.position);
+                    }
+
+                    sep.push_unseparated(")");
+
                     sql.build().execute(&mut *tx).await?;
                 }
             }
@@ -380,6 +391,52 @@ pub async fn update_workspace_entry(
     }
 }
 
+pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<WorkspaceRequest> {
+    let entry = sqlx::query_as!(
+        WorkspaceEntryBase,
+        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE id = ?",
+        id,
+    )
+    .fetch_one(&db)
+    .await?;
+
+    let params = sqlx::query_as!(
+                RequestParams,
+                r#"
+                   SELECT rp.request_id as id, method as 'method!', url as 'url!', content_type as "content_type: _", body AS "body: _"
+                   FROM request_params rp
+                   LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
+                   WHERE rp.request_id = ? 
+                "#,
+                id
+            )
+            .fetch_one(&db)
+            .await?;
+
+    let headers = sqlx::query_as!(
+        RequestHeader,
+        "SELECT id, name, value FROM request_headers WHERE request_id = ?",
+        entry.id
+    )
+    .fetch_all(&db)
+    .await?;
+
+    let path_params = sqlx::query_as!(
+        RequestPathParam,
+        "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
+        entry.id
+    )
+    .fetch_all(&db)
+    .await?;
+
+    Ok(WorkspaceRequest::from_params_and_headers(
+        entry,
+        params,
+        headers,
+        path_params,
+    ))
+}
+
 pub async fn get_workspace_entries(
     db: SqlitePool,
     workspace_id: i64,
@@ -423,7 +480,7 @@ pub async fn get_workspace_entries(
 
                 let path_params = sqlx::query_as!(
                     RequestPathParam,
-                    "SELECT name, value FROM request_path_params WHERE request_id = ?",
+                    "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
                     entry.id
                 )
                 .fetch_all(&db)
@@ -640,7 +697,7 @@ pub async fn delete_env_var(db: SqlitePool, id: i64) -> AppResult<()> {
 pub async fn list_request_path_params(db: SqlitePool, id: i64) -> AppResult<Vec<RequestPathParam>> {
     Ok(sqlx::query_as!(
         RequestPathParam,
-        "SELECT name, value FROM request_path_params WHERE request_id = ?",
+        "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
         id
     )
     .fetch_all(&db)

+ 1 - 0
src-tauri/src/lib.rs

@@ -39,6 +39,7 @@ pub fn run() {
             cmd::parse_url,
             cmd::update_url,
             cmd::expand_url,
+            cmd::send_request,
             cmd::list_environments,
             cmd::create_env,
             cmd::update_env,

+ 40 - 15
src-tauri/src/request.rs

@@ -1,12 +1,15 @@
 pub mod ctype;
 pub mod url;
 
+use std::str::FromStr;
+
 use crate::{request::ctype::ContentType, workspace::WorkspaceEntryBase, AppResult};
 use reqwest::{
     header::{self, HeaderMap, HeaderValue},
-    Body, StatusCode,
+    Body, Method,
 };
 use serde::{Deserialize, Serialize};
+use sqlx::prelude::FromRow;
 use tauri_plugin_log::log;
 
 pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
@@ -63,7 +66,7 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
             let status = res.status();
             let headers = res.headers().clone();
             let body = ResponseBody::try_from_response(res).await?;
-            HttpResponse::new(status, headers, body)
+            HttpResponse::new(status.as_u16() as usize, headers, body)
         }
         Err(e) => return Err(e.into()),
     };
@@ -135,24 +138,51 @@ pub struct HttpRequestParameters {
     pub body: Option<RequestBody>,
 }
 
-#[derive(Debug, Clone)]
+impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
+    type Error = String;
+
+    fn try_from(value: WorkspaceRequest) -> Result<Self, String> {
+        let method = match Method::from_str(&value.method) {
+            Ok(m) => m,
+            Err(e) => return Err(e.to_string()),
+        };
+
+        let mut headers = HeaderMap::new();
+
+        for header in value.headers {
+            headers.insert(
+                reqwest::header::HeaderName::from_str(&header.name).map_err(|e| e.to_string())?,
+                HeaderValue::from_str(&header.value).map_err(|e| e.to_string())?,
+            );
+        }
+
+        Ok(Self {
+            url: value.url,
+            method,
+            headers,
+            body: value.body,
+        })
+    }
+}
+
+#[derive(Debug, Serialize)]
 pub struct HttpResponse {
-    pub status: StatusCode,
-    pub headers: HeaderMap,
+    pub status: usize,
+    // pub headers: HeaderMap,
     pub body: Option<ResponseBody>,
 }
 
 impl HttpResponse {
-    pub fn new(status: StatusCode, headers: HeaderMap, body: Option<ResponseBody>) -> Self {
+    pub fn new(status: usize, headers: HeaderMap, body: Option<ResponseBody>) -> Self {
         Self {
             status,
-            headers,
+            // headers,
             body,
         }
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize)]
 pub enum ResponseBody {
     Text(String),
 
@@ -222,8 +252,9 @@ pub struct RequestParams {
     pub body: Option<String>,
 }
 
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Clone, Deserialize, Serialize, FromRow)]
 pub struct RequestPathParam {
+    pub position: i64,
     pub name: String,
     pub value: String,
 }
@@ -242,12 +273,6 @@ pub struct RequestHeaderUpdate {
     pub value: Option<String>,
 }
 
-#[derive(Debug, Deserialize)]
-pub struct RequestPathUpdate {
-    pub name: String,
-    pub value: String,
-}
-
 #[derive(Debug, Serialize, Deserialize)]
 pub struct RequestBody {
     pub content: String,

+ 204 - 57
src-tauri/src/request/url.rs

@@ -1,5 +1,3 @@
-use std::{collections::HashMap, fmt::Display};
-
 use nom::{
     bytes::complete::{tag, take_while, take_while1},
     character::complete::char,
@@ -8,6 +6,7 @@ use nom::{
     Parser,
 };
 use serde::{Deserialize, Serialize};
+use std::{collections::HashMap, fmt::Display};
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct RequestUrlOwned {
@@ -21,8 +20,8 @@ pub struct RequestUrlOwned {
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(tag = "type", content = "value")]
 pub enum SegmentOwned {
-    Static(String),
-    Dynamic(String),
+    Static(String, usize),
+    Dynamic(String, usize),
 }
 
 /// A fully deconstructed URL from a workspace request.
@@ -56,7 +55,7 @@ pub struct RequestUrl<'a> {
 type NomError<'a> = nom::Err<(&'a str, nom::error::ErrorKind)>;
 
 impl<'a> RequestUrl<'a> {
-    pub fn parse(input: &'a str) -> Result<Self, UrlParseError> {
+    pub fn parse(original_input: &'a str) -> Result<Self, UrlParseError> {
         fn parse_query(query: &str) -> Result<(Vec<(&str, &str)>, bool), NomError<'_>> {
             if query.is_empty() {
                 return Ok((vec![], false));
@@ -95,13 +94,17 @@ impl<'a> RequestUrl<'a> {
             Ok((query_params, trailing_pair))
         }
 
-        let (input, scheme) = match take_while1(char::is_alphabetic)(input) {
+        let mut offset = 0;
+
+        let (input, scheme) = match take_while1(char::is_alphabetic)(original_input) {
             Ok((i, s)) => (i, s),
-            Err(e) => return Err(map_nom_err(input, None, e)),
+            Err(e) => return Err(map_nom_err(original_input, None, e)),
         };
 
         let (input, _) = tag("://")(input).map_err(|e| map_nom_err(input, Some("://"), e))?;
 
+        offset += scheme.len() + 3;
+
         // Parse until first /
 
         let (path, host) =
@@ -126,11 +129,16 @@ impl<'a> RequestUrl<'a> {
             });
         }
 
+        offset += host.len();
+
         // Parse until query
 
         let (query, path) =
             take_while(|c| c != '?')(path).map_err(|e| map_nom_err(path, None, e))?;
 
+        #[cfg(debug_assertions)]
+        debug_assert_eq!(&original_input[offset..], path.to_owned() + query);
+
         let (query_params, trailing) =
             parse_query(query).map_err(|e| map_nom_err(query, None, e))?;
 
@@ -138,26 +146,37 @@ impl<'a> RequestUrl<'a> {
             .parse(path)
             .map_err(|e| map_nom_err(path, None, e))?;
 
+        let mut path = vec![];
+
+        for segment in segments {
+            match preceded(
+                tag::<_, _, nom::error::Error<_>>(":"),
+                nom::combinator::rest,
+            )
+            .parse(segment)
+            {
+                Ok((remainder, segment)) => {
+                    debug_assert_eq!("", remainder);
+                    path.push(Segment::Dynamic(segment, offset));
+                    // account for :
+                    offset += segment.len() + 1;
+                }
+                Err(_) => {
+                    path.push(Segment::Static(segment, offset));
+                    offset += segment.len();
+                }
+            }
+
+            // account for the parsed /
+            offset += 1;
+        }
+
         debug_assert!(remainder.is_empty());
 
         Ok(RequestUrl {
             scheme,
             host,
-            path: segments
-                .into_iter()
-                .map(|segment| {
-                    preceded(
-                        tag::<_, _, nom::error::Error<_>>(":"),
-                        nom::combinator::rest,
-                    )
-                    .parse(segment)
-                    .ok()
-                    .map_or(Segment::Static(segment), |(r, s)| {
-                        debug_assert_eq!("", r);
-                        Segment::Dynamic(s)
-                    })
-                })
-                .collect(),
+            path,
             query_params,
             trailing_query: query == "?",
             trailing_query_pair: trailing,
@@ -165,10 +184,36 @@ impl<'a> RequestUrl<'a> {
     }
 
     pub fn populate(&mut self, path_params: HashMap<&'a str, &'a str>) {
+        let mut total_displaced = 0i64;
+
         for path in self.path.iter_mut() {
-            if let Segment::Dynamic(value) = path {
-                let val = path_params.get(value).unwrap_or(&"");
-                std::mem::swap(path, &mut Segment::Static(val));
+            match path {
+                Segment::Dynamic(value, offset) => {
+                    dbg!(*offset, total_displaced);
+                    let value_len = value.len();
+
+                    let new = path_params.get(value).unwrap_or(&"");
+
+                    let offset = if total_displaced < 0 {
+                        *offset - total_displaced.abs() as usize
+                    } else {
+                        *offset + total_displaced as usize
+                    };
+
+                    *path = Segment::Static(new, offset);
+
+                    total_displaced += new.len() as i64 - value_len as i64;
+
+                    // Account for the :
+                    total_displaced -= 1;
+                }
+                Segment::Static(_, offset) => {
+                    if total_displaced < 0 {
+                        *offset -= total_displaced.abs() as usize;
+                    } else {
+                        *offset += total_displaced as usize;
+                    }
+                }
             }
         }
     }
@@ -238,32 +283,25 @@ impl<'a> Display for RequestUrl<'a> {
     }
 }
 
+/// First value is the parameter name, second is the position of the segment in the input
+/// (including the /).
 #[derive(Debug, PartialEq, Eq)]
 pub enum Segment<'a> {
     /// Path segments that do not change.
     /// The value is the final path value.
-    Static(&'a str),
+    Static(&'a str, usize),
 
     /// Path segments that depend on request configuration.
     /// The value is the name of the variable in the request configuration
     /// that contains the final path value.
-    Dynamic(&'a str),
-}
-
-impl<'a> Segment<'a> {
-    pub fn value(&self) -> &'a str {
-        match self {
-            Segment::Static(s) => s,
-            Segment::Dynamic(s) => s,
-        }
-    }
+    Dynamic(&'a str, usize),
 }
 
 impl<'a> From<Segment<'a>> for SegmentOwned {
     fn from(value: Segment<'a>) -> Self {
         match value {
-            Segment::Static(s) => Self::Static(s.to_owned()),
-            Segment::Dynamic(s) => Self::Dynamic(s.to_owned()),
+            Segment::Static(s, pos) => Self::Static(s.to_owned(), pos),
+            Segment::Dynamic(s, pos) => Self::Dynamic(s.to_owned(), pos),
         }
     }
 }
@@ -271,8 +309,8 @@ impl<'a> From<Segment<'a>> for SegmentOwned {
 impl<'a> Display for Segment<'a> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-            Segment::Static(p) => write!(f, "{p}"),
-            Segment::Dynamic(p) => write!(f, ":{p}"),
+            Segment::Static(p, _) => write!(f, "{p}"),
+            Segment::Dynamic(p, _) => write!(f, ":{p}"),
         }
     }
 }
@@ -344,6 +382,8 @@ where
 
 #[cfg(test)]
 mod tests {
+    use std::collections::HashMap;
+
     use super::{RequestUrl, Segment};
 
     #[test]
@@ -351,9 +391,9 @@ mod tests {
         let input = "http://localhost:4000/foo/:bar/bax";
 
         let expected_path = vec![
-            Segment::Static("foo"),
-            Segment::Dynamic("bar"),
-            Segment::Static("bax"),
+            Segment::Static("foo", "http://localhost:4000".len()),
+            Segment::Dynamic("bar", "http://localhost:4000/foo".len()),
+            Segment::Static("bax", "http://localhost:4000/foo/:bar".len()),
         ];
 
         let url = RequestUrl::parse(input).unwrap();
@@ -369,10 +409,10 @@ mod tests {
         let input = "http://localhost:4000/foo/:bar/bax/";
 
         let expected_path = vec![
-            Segment::Static("foo"),
-            Segment::Dynamic("bar"),
-            Segment::Static("bax"),
-            Segment::Static(""),
+            Segment::Static("foo", "http://localhost:4000".len()),
+            Segment::Dynamic("bar", "http://localhost:4000/foo".len()),
+            Segment::Static("bax", "http://localhost:4000/foo/:bar".len()),
+            Segment::Static("", "http://localhost:4000/foo/:bar/bax".len()),
         ];
 
         let url = RequestUrl::parse(input).unwrap();
@@ -403,7 +443,13 @@ mod tests {
 
         assert_eq!("http", url.scheme);
         assert_eq!("localhost:4000", url.host);
-        assert_eq!(vec![Segment::Static(""), Segment::Static("")], url.path);
+        assert_eq!(
+            vec![
+                Segment::Static("", "http://localhost:4000".len()),
+                Segment::Static("", "http://localhost:4000/".len())
+            ],
+            url.path
+        );
         assert!(url.query_params.is_empty());
     }
 
@@ -415,7 +461,13 @@ mod tests {
 
         assert_eq!("http", url.scheme);
         assert_eq!("localhost:4000", url.host);
-        assert_eq!(vec![Segment::Dynamic(""), Segment::Dynamic("")], url.path);
+        assert_eq!(
+            vec![
+                Segment::Dynamic("", "http://localhost:4000".len()),
+                Segment::Dynamic("", "http://localhost:4000/:".len())
+            ],
+            url.path
+        );
         assert!(url.query_params.is_empty());
     }
 
@@ -427,7 +479,10 @@ mod tests {
 
         assert_eq!("http", url.scheme);
         assert_eq!("localhost:4000", url.host);
-        assert_eq!(vec![Segment::Static("")], url.path);
+        assert_eq!(
+            vec![Segment::Static("", "http://localhost:4000".len())],
+            url.path
+        );
         assert!(url.query_params.is_empty());
     }
 
@@ -453,9 +508,9 @@ mod tests {
         assert_eq!("localhost:4000", url.host);
         assert_eq!(
             vec![
-                Segment::Static("foo"),
-                Segment::Dynamic("bar"),
-                Segment::Dynamic("qux")
+                Segment::Static("foo", "http://localhost:4000".len()),
+                Segment::Dynamic("bar", "http://localhost:4000/foo".len()),
+                Segment::Dynamic("qux", "http://localhost:4000/foo/:bar".len())
             ],
             url.path
         );
@@ -472,13 +527,105 @@ mod tests {
         assert_eq!("localhost:4000", url.host);
         assert_eq!(
             vec![
-                Segment::Static("foo"),
-                Segment::Dynamic("bar"),
-                Segment::Dynamic("qux"),
-                Segment::Static("")
+                Segment::Static("foo", "http://localhost:4000".len()),
+                Segment::Dynamic("bar", "http://localhost:4000/foo".len()),
+                Segment::Dynamic("qux", "http://localhost:4000/foo/:bar".len()),
+                Segment::Static("", "http://localhost:4000/foo/:bar/:qux".len())
             ],
             url.path
         );
         assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
     }
+
+    #[test]
+    fn populate_adjusts_positions() {
+        let input = "http://localhost:4000/foo/:bar/qux/:baz/final";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(
+            vec![
+                Segment::Static("foo", "http://localhost:4000".len()),
+                Segment::Dynamic("bar", "http://localhost:4000/foo".len()),
+                Segment::Static("qux", "http://localhost:4000/foo/:bar".len()),
+                Segment::Dynamic("baz", "http://localhost:4000/foo/:bar/qux".len()),
+                Segment::Static("final", "http://localhost:4000/foo/:bar/qux/:baz".len()),
+            ],
+            url.path
+        );
+
+        url.populate(HashMap::from([("bar", "VALUE"), ("baz", "EULAV")]));
+
+        assert_eq!(
+            "http://localhost:4000/foo/VALUE/qux/EULAV/final",
+            url.to_string()
+        );
+
+        assert_eq!(
+            vec![
+                Segment::Static("foo", "http://localhost:4000".len()),
+                Segment::Static("VALUE", "http://localhost:4000/foo".len()),
+                Segment::Static("qux", "http://localhost:4000/foo/VALUE".len()),
+                Segment::Static("EULAV", "http://localhost:4000/foo/VALUE/qux".len()),
+                Segment::Static("final", "http://localhost:4000/foo/VALUE/qux/EULAV".len()),
+            ],
+            url.path
+        );
+    }
+
+    #[test]
+    fn populate_adjusts_positions_empty() {
+        let input = "http://foo.com/:ID/:myID";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!(
+            vec![
+                Segment::Dynamic("ID", "http://foo.com".len()),
+                Segment::Dynamic("myID", "http://foo.com/:ID".len()),
+            ],
+            url.path
+        );
+
+        url.populate(HashMap::from([("ID", ""), ("myID", "")]));
+
+        assert_eq!("http://foo.com//", url.to_string());
+
+        assert_eq!(
+            vec![
+                Segment::Static("", "http://foo.com".len()),
+                Segment::Static("", "http://foo.com/".len())
+            ],
+            url.path
+        );
+    }
+
+    #[test]
+    fn populate_adjusts_positions_trailing() {
+        let input = "http://foo.com/:ID/";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!(
+            vec![
+                Segment::Dynamic("ID", "http://foo.com".len()),
+                Segment::Static("", "http://foo.com/:ID".len()),
+            ],
+            url.path
+        );
+
+        url.populate(HashMap::from([("ID", "FOO")]));
+
+        assert_eq!("http://foo.com/FOO/", url.to_string());
+
+        assert_eq!(
+            vec![
+                Segment::Static("FOO", "http://foo.com".len()),
+                Segment::Static("", "http://foo.com/FOO".len())
+            ],
+            url.path
+        );
+    }
 }

+ 5 - 1
src-tauri/src/state.rs

@@ -3,6 +3,7 @@ use tauri_plugin_log::log;
 pub struct AppState {
     /// Sqlite database. Just an Arc so cheap to clone.
     pub db: sqlx::sqlite::SqlitePool,
+    pub client: reqwest::Client,
 }
 
 impl AppState {
@@ -12,6 +13,9 @@ impl AppState {
 
         log::info!("State loaded");
 
-        Self { db }
+        Self {
+            db,
+            client: reqwest::Client::new(),
+        }
     }
 }

+ 2 - 5
src-tauri/src/workspace.rs

@@ -3,10 +3,7 @@ use sqlx::prelude::Type;
 
 use crate::{
     db::Update,
-    request::{
-        RequestBody, RequestHeader, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
-        WorkspaceRequest,
-    },
+    request::{RequestBody, RequestHeaderUpdate, RequestPathParam, WorkspaceRequest},
 };
 
 #[derive(Debug, Serialize)]
@@ -85,7 +82,7 @@ pub enum WorkspaceEntryUpdate {
         url: Option<String>,
         body: Option<Update<RequestBody>>,
         headers: Option<Vec<RequestHeaderUpdate>>,
-        path_params: Option<Vec<RequestPathUpdate>>,
+        path_params: Option<Vec<RequestPathParam>>,
     },
 }
 

+ 71 - 64
src/lib/components/WorkspaceEntry.svelte

@@ -1,9 +1,8 @@
 <script lang="ts">
   import {
     state as _state,
-    expandUrl,
-    parseUrl,
     selectEntry,
+    sendRequest,
     updateEntryName,
     updateUrl,
   } from "$lib/state.svelte";
@@ -11,14 +10,7 @@
   import { Input } from "$lib/components/ui/input";
   import * as Accordion from "$lib/components/ui/accordion";
   import * as Tabs from "$lib/components/ui/tabs";
-  import { invoke } from "@tauri-apps/api/core";
-  import type {
-    PathParam,
-    PathSegment,
-    RequestUrl,
-    UrlError,
-  } from "$lib/types";
-  import { onMount } from "svelte";
+  import type { UrlError } from "$lib/types";
   import Editable from "./Editable.svelte";
 
   let headers = [
@@ -30,6 +22,8 @@
     "name": "John Doe"
   }`);
 
+  let response: any = $state();
+
   const referenceChain = $derived.by(() => {
     const parents = [];
 
@@ -43,21 +37,18 @@
     return parents.reverse();
   });
 
-  let url: RequestUrl | null = $state(null);
-  let expanded: string = $state("");
+  async function handleSendRequest() {
+    sendRequest()
+      .then((res) => (response = res))
+      .catch((e) => console.error("error sending request", e));
+  }
 
   async function handleUrlUpdate(direct = false) {
     const u = direct ? _state.entry!!.url : constructUrl();
     console.log(u);
 
     try {
-      url = await updateUrl(u);
-      expandUrl()
-        .then((full) => {
-          console.log("expanded", full);
-          expanded = full;
-        })
-        .catch((e) => console.error(e));
+      await updateUrl(u);
     } catch (err) {
       console.error(err);
       const e = err as UrlError;
@@ -82,11 +73,10 @@
 
   /** Construct a URL from the binded input values for query and path parameters. */
   function constructUrl(): string {
-    console.log($state.snapshot(_state.entry.path_params));
-    console.log($state.snapshot(url));
+    console.log($state.snapshot(_state.entry.path));
     let path = "";
-    if (_state.entry.path_params.length > 0) {
-      for (const param of _state.entry.path_params) {
+    if (_state.entry.path.length > 0) {
+      for (const param of _state.entry.path) {
         if (param.name !== undefined) {
           path += "/:" + param.name;
         } else {
@@ -97,25 +87,28 @@
 
     let query = "";
 
-    if (url!!.query_params.length > 0) {
-      query += "?" + url?.query_params.map((p) => `${p[0]}=${p[1]}`).join("&");
-    } else if (url!!.has_query) {
+    if (_state.entry.workingUrl.query_params.length > 0) {
+      query +=
+        "?" +
+        _state.entry
+          .workingUrl!!.query_params.map((p) => `${p[0]}=${p[1]}`)
+          .join("&");
+    } else if (_state.entry.workingUrl!!.has_query) {
       query += "?";
     }
 
-    return `${url!!.scheme}://${url!!.host}${path}${query}`;
+    return `${_state.entry.workingUrl!!.scheme}://${_state.entry.workingUrl!!.host}${path}${query}`;
   }
 
-  onMount(() => {
-    if (_state.entry?.type === "Request") {
-      parseUrl()
-        .then((u) => {
-          url = u;
-        })
-        .catch((e) => console.error("error parsing url", e));
-      expandUrl().then((u) => (expanded = u));
+  function responseContent() {
+    if (!response) {
+      return "";
     }
-  });
+
+    if (response.body["Json"]) {
+      return response.body["Json"];
+    }
+  }
 </script>
 
 {#snippet entryPath()}
@@ -183,10 +176,10 @@
           }}
         />
 
-        <Button class="w-1/12">Send</Button>
+        <Button class="w-1/12" onclick={() => handleSendRequest()}>Send</Button>
 
         <p class="w-full pl-1 text-xs text-muted-foreground">
-          {expanded}
+          {_state.entry.expandedUrl ?? ""}
         </p>
       </div>
 
@@ -194,48 +187,46 @@
 
       <Accordion.Root
         type="multiple"
-        value={["auth", "params", "headers", "body"]}
+        value={["auth", "params", "headers", "body", "response"]}
         class="w-full"
       >
         <!-- URL PARAMS -->
 
-        {#if url?.path.some((p) => p.type === "Dynamic") || url?.query_params.length > 0}
+        {#if _state.entry.path.length > 0 || _state.entry.workingUrl?.query_params?.length > 0}
           <Accordion.Item value="params">
-            <Accordion.Trigger>Parameters</Accordion.Trigger>
+            <Accordion.Trigger class="transition-none!"
+              >Parameters</Accordion.Trigger
+            >
 
             <!-- PATH PARAMS -->
 
             <Accordion.Content
-              class="border flex-col justify-center items-center space-y-4"
+              class="border flex-col justify-center items-center space-y-4 "
             >
-              {#if _state.entry.path_params.some((p) => p.name !== undefined)}
-                <div class="flex flex-wrap border">
-                  <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
-                  <div
-                    class="border border-pink-900 w-1/2 grid grid-cols-2 gap-2 text-sm"
-                  >
-                    {#each _state.entry.path_params.filter((p) => p.name !== undefined) as param}
-                      <Input
-                        bind:value={param.name}
-                        placeholder="key"
-                        oninput={() => handleUrlUpdate()}
-                      />
-                      <Input
-                        bind:value={param.value}
-                        placeholder="value"
-                        oninput={() => handleUrlUpdate()}
-                      />
-                    {/each}
-                  </div>
+              <div class="flex flex-wrap border">
+                <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
+                <div class="w-1/2 grid grid-cols-2 gap-2 text-sm">
+                  {#each _state.entry.path as param}
+                    <Input
+                      bind:value={param.name}
+                      placeholder="key"
+                      oninput={() => handleUrlUpdate()}
+                    />
+                    <Input
+                      bind:value={param.value}
+                      placeholder="value"
+                      oninput={() => handleUrlUpdate()}
+                    />
+                  {/each}
                 </div>
-              {/if}
+              </div>
 
               <!-- QUERY PARAMS -->
 
-              {#if url?.query_params.length > 0}
+              {#if _state.entry.workingUrl?.query_params.length > 0}
                 <h3 class="w-full border-b mb-2 text-sm font-medium">Query</h3>
                 <div class="grid items-center grid-cols-2 gap-2 text-sm">
-                  {#each url!!.query_params as param}
+                  {#each _state.entry.workingUrl!!.query_params as param}
                     <Input
                       bind:value={param[0]}
                       placeholder="key"
@@ -276,6 +267,7 @@
         </Accordion.Item>
 
         <!-- BODY -->
+
         <Accordion.Item value="body">
           <Accordion.Trigger>Body</Accordion.Trigger>
           <Accordion.Content class="space-y-4">
@@ -308,6 +300,21 @@
             </Tabs.Root>
           </Accordion.Content>
         </Accordion.Item>
+
+        <!-- RESPONSE -->
+
+        {#if response}
+          <Accordion.Item value="response">
+            <Accordion.Trigger>Response</Accordion.Trigger>
+            <Accordion.Content class="space-y-4">
+              <textarea
+                class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
+                bind:value={response.body["Json"]}
+                readonly
+              ></textarea>
+            </Accordion.Content>
+          </Accordion.Item>
+        {/if}
       </Accordion.Root>
     </section>
   {/if}

+ 15 - 15
src/lib/components/ui/accordion/accordion-content.svelte

@@ -1,22 +1,22 @@
 <script lang="ts">
-	import { Accordion as AccordionPrimitive } from "bits-ui";
-	import { cn, type WithoutChild } from "$lib/utils.js";
+  import { Accordion as AccordionPrimitive } from "bits-ui";
+  import { cn, type WithoutChild } from "$lib/utils.js";
 
-	let {
-		ref = $bindable(null),
-		class: className,
-		children,
-		...restProps
-	}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
+  let {
+    ref = $bindable(null),
+    class: className,
+    children,
+    ...restProps
+  }: WithoutChild<AccordionPrimitive.ContentProps> = $props();
 </script>
 
 <AccordionPrimitive.Content
-	bind:ref
-	data-slot="accordion-content"
-	class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
-	{...restProps}
+  bind:ref
+  data-slot="accordion-content"
+  class="overflow-hidden text-sm"
+  {...restProps}
 >
-	<div class={cn("pt-0 pb-4", className)}>
-		{@render children?.()}
-	</div>
+  <div class={cn("pt-0 pb-4", className)}>
+    {@render children?.()}
+  </div>
 </AccordionPrimitive.Content>

+ 26 - 26
src/lib/components/ui/accordion/accordion-trigger.svelte

@@ -1,32 +1,32 @@
 <script lang="ts">
-	import { Accordion as AccordionPrimitive } from "bits-ui";
-	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
-	import { cn, type WithoutChild } from "$lib/utils.js";
+  import { Accordion as AccordionPrimitive } from "bits-ui";
+  import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+  import { cn, type WithoutChild } from "$lib/utils.js";
 
-	let {
-		ref = $bindable(null),
-		class: className,
-		level = 3,
-		children,
-		...restProps
-	}: WithoutChild<AccordionPrimitive.TriggerProps> & {
-		level?: AccordionPrimitive.HeaderProps["level"];
-	} = $props();
+  let {
+    ref = $bindable(null),
+    class: className,
+    level = 3,
+    children,
+    ...restProps
+  }: WithoutChild<AccordionPrimitive.TriggerProps> & {
+    level?: AccordionPrimitive.HeaderProps["level"];
+  } = $props();
 </script>
 
 <AccordionPrimitive.Header {level} class="flex">
-	<AccordionPrimitive.Trigger
-		data-slot="accordion-trigger"
-		bind:ref
-		class={cn(
-			"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
-			className
-		)}
-		{...restProps}
-	>
-		{@render children?.()}
-		<ChevronDownIcon
-			class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
-		/>
-	</AccordionPrimitive.Trigger>
+  <AccordionPrimitive.Trigger
+    data-slot="accordion-trigger"
+    bind:ref
+    class={cn(
+      "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+      className,
+    )}
+    {...restProps}
+  >
+    {@render children?.()}
+    <ChevronDownIcon
+      class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
+    />
+  </AccordionPrimitive.Trigger>
 </AccordionPrimitive.Header>

+ 49 - 32
src/lib/state.svelte.ts

@@ -7,7 +7,8 @@ import type {
   WorkspaceEnvironment,
   EnvVariable,
   RequestUrl,
-  WorkspaceRequest,
+  RequestHeader,
+  RequestPathParam,
 } from "./types";
 import { getSetting, setSetting } from "./settings.svelte";
 
@@ -96,7 +97,7 @@ export async function selectEnvironment(id: number | null) {
     env = { [state.workspace!!.id]: id };
   }
   setSetting("lastEnvironment", env);
-  console.debug("selected environment:", state.environment.name);
+  console.debug("selected environment:", state.environment?.name);
 }
 
 export function selectWorkspace(ws: Workspace) {
@@ -104,9 +105,11 @@ export function selectWorkspace(ws: Workspace) {
   state.workspace = ws;
 }
 
-export function selectEntry(id: number) {
+export async function selectEntry(id: number) {
   state.entry = state.indexes[id];
+
   console.log("selected entry:", state.entry);
+
   if (state.entry.parent_id !== null) {
     let parent = state.indexes[state.entry.parent_id];
     while (parent) {
@@ -117,6 +120,23 @@ export function selectEntry(id: number) {
       parent = state.indexes[parent.parent_id];
     }
   }
+
+  if (state.entry.type === "Request") {
+    parseUrl(state.entry!!.url)
+      .then(() =>
+        console.log("working URL:", $state.snapshot(state.entry.workingUrl)),
+      )
+      .catch((e) => {
+        console.error("error parsing URL", e);
+      });
+    expandUrl()
+      .then(() =>
+        console.log("expanded URL:", $state.snapshot(state.entry.expandedUrl)),
+      )
+      .catch((e) => {
+        console.error("error expanding URL", e);
+      });
+  }
 }
 
 // COMMANDS
@@ -149,7 +169,7 @@ export async function loadWorkspace(ws: Workspace) {
         url: entry.data.url,
         headers: entry.data.headers,
         body: entry.data.body,
-        path_params: entry.data.path_params,
+        path: entry.data.path_params,
       });
     } else {
       index(entry.data);
@@ -184,7 +204,7 @@ export function createRequest(parentId?: number) {
       url: data.Request.url,
       body: null,
       headers: [],
-      path_params: [],
+      path: [],
     });
     console.log("request created:", entry);
   });
@@ -246,6 +266,14 @@ export async function updateEnvironment() {
   });
 }
 
+export async function sendRequest(): Promise<any> {
+  const res = await invoke("send_request", { reqId: state.entry!!.id });
+
+  console.debug(res);
+
+  return res;
+}
+
 export async function updateEntryName(name: string) {
   if (!state.entry) {
     console.warn("attempted to persist null entry");
@@ -271,10 +299,10 @@ export async function updateEntryName(name: string) {
   });
 }
 
-export async function parseUrl(): Promise<RequestUrl> {
-  console.debug("parsing", $state.snapshot(state.entry!!.url));
-  return invoke<RequestUrl>("parse_url", {
-    url: state.entry!!.url,
+export async function parseUrl(url: string) {
+  console.debug("parsing", $state.snapshot(url));
+  state.entry!!.workingUrl = await invoke<RequestUrl>("parse_url", {
+    url,
     envId: state.environment?.id,
   });
 }
@@ -282,13 +310,13 @@ export async function parseUrl(): Promise<RequestUrl> {
 export async function updateUrl(u: string): Promise<RequestUrl> {
   console.debug("updating", $state.snapshot(state.entry));
 
-  const pathParams = {};
+  const pathParams: Record<string, string> = {};
 
-  for (const p of state.entry.path_params) {
-    pathParams[p.name] = p.value;
+  for (const path of state.entry.path) {
+    pathParams[path.name] = path.value;
   }
 
-  const url = await invoke<RequestUrl>("update_url", {
+  const [url, params] = await invoke<any[]>("update_url", {
     entryId: state.entry!!.id,
     envId: state.environment?.id,
     url: u,
@@ -296,29 +324,18 @@ export async function updateUrl(u: string): Promise<RequestUrl> {
   });
 
   state.entry!!.url = u;
+  state.entry.path = params;
+  state.entry.workingUrl = url;
 
-  state.entry!!.path_params = [];
-
-  for (const path of url.path) {
-    if (path.type === "Dynamic") {
-      state.entry!!.path_params.push({
-        name: path.value,
-        value: pathParams[path.value] ?? "",
-      });
-    } else {
-      state.entry!!.path_params.push({
-        value: path.value,
-      });
-    }
-  }
-
-  console.log();
+  expandUrl();
 
+  console.debug("updated", $state.snapshot(state.entry));
   return url;
 }
 
 export async function expandUrl() {
-  return invoke<string>("expand_url", {
+  console.debug("expanding URL", $state.snapshot(state.entry.url));
+  state.entry!!.expandedUrl = await invoke<string>("expand_url", {
     entryId: state.entry!!.id,
     envId: state.environment?.id,
     url: state.entry!!.url,
@@ -382,6 +399,6 @@ type WorkspaceRequestResponse = {
   method: string;
   url: string;
   body: RequestBody | null;
-  headers: RequestKVParam[];
-  path_params: RequestKVParam[];
+  headers: RequestHeader[];
+  path_params: RequestPathParam[];
 };

+ 15 - 3
src/lib/types.ts

@@ -23,8 +23,13 @@ export type WorkspaceRequest = WorkspaceEntryBase & {
   method: string;
   url: string;
   body: RequestBody | null;
-  headers: PathParam[];
-  path: (PathParam & { current: string })[];
+  headers: RequestHeader[];
+  path: RequestPathParam[];
+
+  // Display fields
+
+  workingUrl?: RequestUrl;
+  expandedUrl?: RequestUrl;
 };
 
 export type RequestUrl = {
@@ -42,7 +47,14 @@ export type UrlError = {
   error: string;
 };
 
-export type PathParam = {
+export type RequestHeader = {
+  id: number;
+  name: string;
+  value: string;
+};
+
+export type RequestPathParam = {
+  position: number;
   name: string;
   value: string;
 };

+ 2 - 1
src/routes/layout.css

@@ -115,7 +115,8 @@
   * {
     @apply border-border outline-ring/50;
   }
+
   body {
     @apply bg-background text-foreground;
   }
-}
+}