Ver Fonte

Fix URL

biblius há 3 semanas atrás
pai
commit
4b5f00cd17

+ 1 - 0
src-tauri/migrations/20250922150745_init.down.sql

@@ -1,6 +1,7 @@
 DROP TABLE request_headers;
 DROP TABLE request_bodies;
 DROP TABLE request_params;
+DROP TABLE request_path_params;
 DROP TABLE workspace_entries;
 DROP TABLE workspace_env_variables;
 DROP TABLE workspace_envs;

+ 8 - 0
src-tauri/migrations/20250922150745_init.up.sql

@@ -42,6 +42,14 @@ CREATE TABLE request_params (
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
+CREATE TABLE request_path_params (
+    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
+);
+
 CREATE TABLE request_bodies (
     id INTEGER PRIMARY KEY NOT NULL,
     request_id UNIQUE NOT NULL,

+ 174 - 6
src-tauri/src/cmd.rs

@@ -1,12 +1,18 @@
+use std::collections::HashMap;
+
 use tauri_plugin_log::log;
 
 use crate::{
     db,
-    request::url::{RequestUrl, RequestUrlOwned},
+    request::{
+        url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
+        RequestPathUpdate,
+    },
     state::AppState,
+    var::{expand_vars, parse_vars},
     workspace::{
-        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEnvVariable,
-        WorkspaceEnvironment,
+        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryUpdate,
+        WorkspaceEntryUpdateBase, WorkspaceEnvVariable, WorkspaceEnvironment,
     },
 };
 
@@ -52,12 +58,174 @@ pub async fn create_workspace_entry(
 }
 
 #[tauri::command]
-pub fn parse_url(url: String) -> Result<RequestUrlOwned, String> {
+pub async fn update_workspace_entry(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+    data: WorkspaceEntryUpdate,
+) -> Result<(), String> {
+    match db::update_workspace_entry(state.db.clone(), entry_id, data).await {
+        Ok(()) => Ok(()),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn parse_url(
+    state: tauri::State<'_, AppState>,
+    env_id: Option<i64>,
+    mut url: String,
+) -> Result<RequestUrlOwned, UrlError> {
+    if let Some(env_id) = env_id {
+        let vars = match parse_vars(&url) {
+            Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
+            Err(e) => return Err(UrlError::Var(e.to_string())),
+        };
+
+        if !vars.is_empty() {
+            let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+                Ok(v) => v,
+                Err(e) => return Err(UrlError::Db(e.to_string())),
+            };
+
+            url = expand_vars(&url, &vars);
+        }
+    }
+
     match RequestUrl::parse(&url) {
         Ok(url) => Ok(url.into()),
         Err(e) => {
-            log::debug!("{e}");
-            Err(e.to_string())
+            log::debug!("{e:?}");
+            Err(UrlError::Parse(e))
+        }
+    }
+}
+
+#[tauri::command]
+pub async fn expand_url(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+    env_id: Option<i64>,
+    mut url: String,
+) -> Result<String, UrlError> {
+    if let Some(env_id) = env_id {
+        let vars = match parse_vars(&url) {
+            Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
+            Err(e) => return Err(UrlError::Var(e.to_string())),
+        };
+
+        if !vars.is_empty() {
+            let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+                Ok(v) => v,
+                Err(e) => return Err(UrlError::Db(e.to_string())),
+            };
+
+            url = expand_vars(&url, &vars);
+        }
+    }
+
+    match RequestUrl::parse(&url) {
+        Ok(mut url) => {
+            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())),
+            };
+
+            url.populate(
+                params
+                    .iter()
+                    .map(|p| (p.name.as_str(), p.value.as_str()))
+                    .collect(),
+            );
+
+            let url = url.to_string();
+
+            let vars = match parse_vars(&url) {
+                Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
+                Err(e) => return Err(UrlError::Var(e.to_string())),
+            };
+
+            if vars.is_empty() {
+                return Ok(url);
+            }
+
+            let Some(env_id) = env_id else {
+                return Ok(url);
+            };
+
+            let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+                Ok(v) => v,
+                Err(e) => return Err(UrlError::Db(e.to_string())),
+            };
+
+            Ok(expand_vars(&url.to_string(), &vars))
+        }
+        Err(e) => {
+            log::debug!("{e:?}");
+            Err(UrlError::Parse(e))
+        }
+    }
+}
+
+#[tauri::command]
+pub async fn update_url(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+    env_id: Option<i64>,
+    url: String,
+    path_params: HashMap<String, String>,
+) -> Result<RequestUrlOwned, 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<_>>(),
+            Err(e) => return Err(UrlError::Var(e.to_string())),
+        };
+
+        let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+            Ok(v) => v,
+            Err(e) => return Err(UrlError::Db(e.to_string())),
+        };
+
+        Some(expand_vars(&url, &vars))
+    } else {
+        None
+    };
+
+    match RequestUrl::parse(url_expanded.as_ref().unwrap_or(&url)) {
+        Ok(url_parsed) => {
+            let mut insert: Vec<RequestPathUpdate> = 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 {
+                        name: seg.to_string(),
+                        value: path_params.get(*seg).cloned().unwrap_or_default(),
+                    })
+                }
+            }
+
+            db::update_workspace_entry(
+                state.db.clone(),
+                entry_id,
+                WorkspaceEntryUpdate::Request {
+                    path_params: Some(insert),
+                    url: Some(url.clone()),
+                    base: WorkspaceEntryUpdateBase::default(),
+                    body: None,
+                    headers: None,
+                    method: None,
+                },
+            )
+            .await
+            .map_err(|e| UrlError::Db(e.to_string()))?;
+
+            Ok(url_parsed.into())
+        }
+        Err(e) => {
+            log::debug!("{e:?}");
+            Err(UrlError::Parse(e))
         }
     }
 }

+ 294 - 30
src-tauri/src/db.rs

@@ -1,32 +1,54 @@
 use crate::{
     error::AppError,
-    request::{ctype::ContentType, WorkspaceRequest},
+    request::{RequestHeader, RequestParams, RequestPathParam, WorkspaceRequest},
     workspace::{
         Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryType,
-        WorkspaceEnvVariable, WorkspaceEnvironment,
+        WorkspaceEntryUpdate, WorkspaceEnvVariable, WorkspaceEnvironment,
     },
     AppResult,
 };
-use sqlx::sqlite::SqlitePool;
+use serde::Deserialize;
+use sqlx::{sqlite::SqlitePool, QueryBuilder};
 use std::collections::HashMap;
 use tauri_plugin_log::log;
 
-#[derive(Debug, Clone)]
-pub struct RequestParams {
-    /// ID of the workspace entry representing this request.
-    pub id: i64,
-    pub method: String,
-    pub url: String,
-    pub content_type: Option<ContentType>,
-    pub body: Option<String>,
+/// Used in update DTOs for **optional** properties. Since it is intended for optional properties, always needs to be wrapped in an option. A value indicates a parameter needs to be updated to whatever is contained
+/// in it, a null indicates to set the field to null.
+#[derive(Debug, Deserialize)]
+pub enum Update<T> {
+    Value(T),
+    Null,
 }
 
-#[derive(Debug, Clone)]
-pub struct RequestHeader {
-    pub name: String,
-    pub value: String,
+impl<T: Clone> Clone for Update<T> {
+    fn clone(&self) -> Self {
+        match self {
+            Self::Value(arg0) => Self::Value(arg0.clone()),
+            Self::Null => Self::Null,
+        }
+    }
+}
+
+impl<T: Copy> Copy for Update<T> {}
+
+impl<T: Copy> Update<T> {
+    pub fn value(self) -> Option<T> {
+        match self {
+            Update::Value(v) => Some(v),
+            Update::Null => None,
+        }
+    }
 }
 
+// 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
@@ -62,6 +84,26 @@ pub async fn list_workspaces(db: SqlitePool) -> AppResult<Vec<Workspace>> {
     )
 }
 
+/// Check whether the entry whose `id` == `parent_id` supports children.
+async fn check_parent(db: &SqlitePool, parent_id: Option<i64>) -> AppResult<()> {
+    let Some(parent_id) = parent_id else {
+        return Ok(());
+    };
+
+    let ty = sqlx::query!("SELECT type FROM workspace_entries WHERE id = ?", parent_id)
+        .fetch_one(db)
+        .await?
+        .r#type;
+
+    if !matches!(WorkspaceEntryType::from(ty), WorkspaceEntryType::Collection) {
+        return Err(AppError::InvalidUpdate(format!(
+            "{parent_id} is not a valid parent ID (type: {ty})"
+        )));
+    }
+
+    Ok(())
+}
+
 pub async fn create_workspace_entry(
     db: SqlitePool,
     entry: WorkspaceEntryCreate,
@@ -72,18 +114,7 @@ pub async fn create_workspace_entry(
             workspace_id,
             parent_id,
         } => {
-            if let Some(parent) = parent_id {
-                let ty = sqlx::query!("SELECT type FROM workspace_entries WHERE id = ?", parent)
-                    .fetch_one(&db)
-                    .await?
-                    .r#type;
-
-                if !matches!(WorkspaceEntryType::from(ty), WorkspaceEntryType::Collection) {
-                    return Err(AppError::InvalidParent(format!(
-                        "{parent} is not a valid parent ID (type: {ty})"
-                    )));
-                }
-            }
+            check_parent(&db, parent_id).await?;
             let entry = sqlx::query_as!(
                 WorkspaceEntryBase,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
@@ -110,7 +141,7 @@ pub async fn create_workspace_entry(
                     .r#type;
 
                 if !matches!(WorkspaceEntryType::from(ty), WorkspaceEntryType::Collection) {
-                    return Err(AppError::InvalidParent(format!(
+                    return Err(AppError::InvalidUpdate(format!(
                         "{parent} is not a valid parent ID (type: {ty})"
                     )));
                 }
@@ -159,6 +190,196 @@ pub async fn create_workspace_entry(
     }
 }
 
+pub async fn update_workspace_entry(
+    db: SqlitePool,
+    entry_id: i64,
+    update: WorkspaceEntryUpdate,
+) -> AppResult<()> {
+    match update {
+        WorkspaceEntryUpdate::Collection(update) => {
+            let mut sql = sqlx::query_builder::QueryBuilder::new("UPDATE workspace_entries SET");
+
+            if let Some(parent) = update.parent_id {
+                check_parent(&db, parent.value()).await?;
+            }
+
+            match (update.name, update.parent_id) {
+                (None, None) => {
+                    return Err(AppError::InvalidUpdate(
+                        "cannot update entry: no updates present".to_string(),
+                    ))
+                }
+                (None, Some(parent_id)) => match parent_id {
+                    Update::Value(v) => {
+                        sql.push("parent_id = ").push_bind(v);
+                    }
+                    Update::Null => {
+                        sql.push("parent_id = NULL ");
+                    }
+                },
+                (Some(name), None) => {
+                    sql.push("name = ").push_bind(name);
+                }
+                (Some(name), Some(parent_id)) => {
+                    match parent_id {
+                        Update::Value(v) => {
+                            sql.push("parent_id = ").push_bind(v);
+                        }
+                        Update::Null => {
+                            sql.push("parent_id = NULL ");
+                        }
+                    };
+                    sql.push(", name = ").push_bind(name);
+                }
+            }
+
+            sql.push("WHERE id = ")
+                .push_bind(entry_id)
+                .build()
+                .execute(&db)
+                .await?;
+
+            Ok(())
+        }
+        WorkspaceEntryUpdate::Request {
+            base,
+            method,
+            url,
+            body,
+            headers,
+            path_params,
+        } => {
+            let mut tx = db.begin().await?;
+
+            'entry: {
+                if let Some(parent) = base.parent_id {
+                    check_parent(&db, parent.value()).await?;
+                }
+
+                let mut sql =
+                    sqlx::query_builder::QueryBuilder::new("UPDATE workspace_entries SET ");
+
+                match (base.name, base.parent_id) {
+                    (None, None) => break 'entry,
+                    (None, Some(parent_id)) => match parent_id {
+                        Update::Value(v) => {
+                            sql.push("parent_id = ").push_bind(v);
+                        }
+                        Update::Null => {
+                            sql.push("parent_id = NULL ");
+                        }
+                    },
+                    (Some(name), None) => {
+                        sql.push("name = ").push_bind(name);
+                    }
+                    (Some(name), Some(parent_id)) => {
+                        match parent_id {
+                            Update::Value(v) => {
+                                sql.push("parent_id = ").push_bind(v);
+                            }
+                            Update::Null => {
+                                sql.push("parent_id = NULL ");
+                            }
+                        };
+                        sql.push(", name = ").push_bind(name);
+                    }
+                }
+
+                sql.push("WHERE id = ")
+                    .push_bind(entry_id)
+                    .build()
+                    .execute(&mut *tx)
+                    .await?;
+            };
+
+            'param: {
+                let mut sql = sqlx::query_builder::QueryBuilder::new("UPDATE request_params ");
+
+                match (method, url) {
+                    (None, None) => break 'param,
+                    (None, Some(url)) => {
+                        sql.push("SET url = ").push_bind(url);
+                    }
+                    (Some(method), None) => {
+                        sql.push("SET method = ").push_bind(method);
+                    }
+                    (Some(method), Some(url)) => {
+                        sql.push("SET method = ")
+                            .push_bind(method)
+                            .push(", url = ")
+                            .push_bind(url);
+                    }
+                }
+
+                sql.push("WHERE request_id = ")
+                    .push_bind(entry_id)
+                    .build()
+                    .execute(&mut *tx)
+                    .await?;
+            };
+
+            if let Some(body) = body {
+                match body {
+                    Update::Value(body) => {
+                        sqlx::query!(
+                            "UPDATE request_bodies SET content_type = ?, body = ? WHERE request_id = ?",
+                            body.ty,
+                            body.content,
+                            entry_id,
+                        )
+                        .execute(&mut *tx)
+                        .await?;
+                    }
+                    Update::Null => {
+                        sqlx::query!("DELETE FROM request_bodies WHERE request_id = ?", entry_id)
+                            .execute(&mut *tx)
+                            .await?;
+                    }
+                }
+            }
+
+            if let Some(headers) = headers {
+                if !headers.is_empty() {
+                    let mut sql = QueryBuilder::new(
+                        "INSERT INTO request_headers(id, request_id, name, value) ",
+                    );
+
+                    sql.push_values(headers, |mut b, header| {
+                        b.push_bind(header.id)
+                            .push_bind(entry_id)
+                            .push_bind(header.name)
+                            .push_bind(header.value);
+                    });
+
+                    sql.build().execute(&mut *tx).await?;
+                }
+            }
+
+            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) ");
+
+                    sql.push_values(path_params, |mut b, path| {
+                        b.push_bind(entry_id)
+                            .push_bind(path.name)
+                            .push_bind(path.value);
+                    });
+
+                    sql.build().execute(&mut *tx).await?;
+                }
+            }
+
+            tx.commit().await?;
+
+            Ok(())
+        }
+    }
+}
+
 pub async fn get_workspace_entries(
     db: SqlitePool,
     workspace_id: i64,
@@ -194,7 +415,15 @@ pub async fn get_workspace_entries(
             WorkspaceEntryType::Request => {
                 let headers = sqlx::query_as!(
                     RequestHeader,
-                    "SELECT name, value FROM request_headers WHERE request_id = ?",
+                    "SELECT id, name, value FROM request_headers WHERE request_id = ?",
+                    entry.id
+                )
+                .fetch_all(&db)
+                .await?;
+
+                let path_params = sqlx::query_as!(
+                    RequestPathParam,
+                    "SELECT name, value FROM request_path_params WHERE request_id = ?",
                     entry.id
                 )
                 .fetch_all(&db)
@@ -205,7 +434,8 @@ pub async fn get_workspace_entries(
                     continue;
                 };
 
-                let req = WorkspaceRequest::from_params_and_headers(entry, params, headers);
+                let req =
+                    WorkspaceRequest::from_params_and_headers(entry, params, headers, path_params);
 
                 out.push(WorkspaceEntry::new_req(req));
             }
@@ -278,6 +508,30 @@ pub async fn list_environments(
     Ok(environments.into_values().collect())
 }
 
+pub async fn get_env_variables(
+    db: SqlitePool,
+    env_id: i64,
+    names: &[&str],
+) -> AppResult<Vec<(String, String)>> {
+    let mut query =
+        QueryBuilder::new("SELECT name, value FROM workspace_env_variables WHERE env_id = ");
+
+    query.push_bind(env_id);
+
+    let mut separated = query.push(" AND name IN (").separated(", ");
+
+    for name in names {
+        separated.push_bind(name);
+    }
+
+    separated.push_unseparated(")");
+
+    Ok(query
+        .build_query_as::<(String, String)>()
+        .fetch_all(&db)
+        .await?)
+}
+
 pub async fn create_environment(
     db: SqlitePool,
     workspace_id: i64,
@@ -382,3 +636,13 @@ pub async fn delete_env_var(db: SqlitePool, id: i64) -> AppResult<()> {
     .await?;
     Ok(())
 }
+
+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 = ?",
+        id
+    )
+    .fetch_all(&db)
+    .await?)
+}

+ 3 - 1
src-tauri/src/error.rs

@@ -10,6 +10,8 @@ pub enum AppError {
     MimeFromStr(#[from] mime::FromStrError),
     #[error("{0}")]
     SerdeJson(#[from] serde_json::Error),
+
+    // Domain specific errors
     #[error("{0}")]
-    InvalidParent(String),
+    InvalidUpdate(String),
 }

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

@@ -35,7 +35,10 @@ pub fn run() {
             cmd::create_workspace,
             cmd::get_workspace_entries,
             cmd::create_workspace_entry,
+            cmd::update_workspace_entry,
             cmd::parse_url,
+            cmd::update_url,
+            cmd::expand_url,
             cmd::list_environments,
             cmd::create_env,
             cmd::update_env,

+ 46 - 32
src-tauri/src/request.rs

@@ -1,22 +1,14 @@
 pub mod ctype;
 pub mod url;
 
-use std::collections::HashMap;
-
+use crate::{request::ctype::ContentType, workspace::WorkspaceEntryBase, AppResult};
 use reqwest::{
     header::{self, HeaderMap, HeaderValue},
     Body, StatusCode,
 };
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use tauri_plugin_log::log;
 
-use crate::{
-    db::{RequestHeader, RequestParams},
-    request::ctype::ContentType,
-    workspace::WorkspaceEntryBase,
-    AppResult,
-};
-
 pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
     ("user-agent", "rquest/0.0.1"),
     ("accept", "*/*"),
@@ -79,7 +71,7 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
     Ok(res)
 }
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 pub struct WorkspaceRequest {
     /// Workspace entry representing this request.
     pub entry: WorkspaceEntryBase,
@@ -94,7 +86,10 @@ pub struct WorkspaceRequest {
     pub body: Option<RequestBody>,
 
     /// HTTP header names => values.
-    pub headers: HashMap<String, String>,
+    pub headers: Vec<RequestHeader>,
+
+    /// URL path keys => values.
+    pub path_params: Vec<RequestPathParam>,
 }
 
 impl WorkspaceRequest {
@@ -104,7 +99,8 @@ impl WorkspaceRequest {
             method,
             url,
             body: None,
-            headers: HashMap::new(),
+            headers: vec![],
+            path_params: vec![],
         }
     }
 
@@ -112,6 +108,7 @@ impl WorkspaceRequest {
         entry: WorkspaceEntryBase,
         params: RequestParams,
         headers: Vec<RequestHeader>,
+        path_params: Vec<RequestPathParam>,
     ) -> Self {
         let body = match (params.body, params.content_type) {
             (Some(content), Some(ty)) => Some(RequestBody { content, ty }),
@@ -123,7 +120,8 @@ impl WorkspaceRequest {
             method: params.method,
             url: params.url,
             body,
-            headers: headers.into_iter().map(|h| (h.name, h.value)).collect(),
+            headers,
+            path_params,
         }
     }
 }
@@ -214,28 +212,44 @@ impl ResponseBody {
     }
 }
 
-#[derive(Debug, Clone, Serialize)]
-pub struct RequestBody {
-    pub content: String,
-    pub ty: ContentType,
+#[derive(Debug)]
+pub struct RequestParams {
+    /// ID of the workspace entry representing this request.
+    pub id: i64,
+    pub method: String,
+    pub url: String,
+    pub content_type: Option<ContentType>,
+    pub body: Option<String>,
 }
 
-#[derive(Debug, Clone)]
-pub enum RequestMessage {
-    UrlUpdated(String),
-    Run(i64),
-    SectionUpdate(RequestSectionUpdate),
+#[derive(Debug, Deserialize, Serialize)]
+pub struct RequestPathParam {
+    pub name: String,
+    pub value: String,
 }
 
-#[derive(Debug, Clone, Copy)]
-pub enum RequestSectionUpdate {
-    Params,
-    Headers,
-    Body,
+#[derive(Debug, Serialize)]
+pub struct RequestHeader {
+    pub id: i64,
+    pub name: String,
+    pub value: String,
 }
 
-#[derive(Debug, Clone)]
-pub enum ResponseMessage {
-    Success(i64, HttpResponse),
-    Error(i64, String),
+#[derive(Debug, Deserialize)]
+pub struct RequestHeaderUpdate {
+    pub id: i64,
+    pub name: Option<String>,
+    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,
+    pub ty: ContentType,
 }

+ 2 - 2
src-tauri/src/request/ctype.rs

@@ -1,7 +1,7 @@
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use sqlx::error::BoxDynError;
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
 pub enum ContentType {
     Text,
     Json,

+ 296 - 102
src-tauri/src/request/url.rs

@@ -1,21 +1,24 @@
+use std::{collections::HashMap, fmt::Display};
+
 use nom::{
-    bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
+    bytes::complete::{tag, take_while, take_while1},
     character::complete::char,
     multi::many0,
     sequence::{preceded, separated_pair},
     Parser,
 };
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 pub struct RequestUrlOwned {
     pub scheme: String,
     pub host: String,
     pub path: Vec<SegmentOwned>,
     pub query_params: Vec<(String, String)>,
+    pub has_query: bool,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(tag = "type", content = "value")]
 pub enum SegmentOwned {
     Static(String),
@@ -40,117 +43,132 @@ pub struct RequestUrl<'a> {
 
     /// Query parameters.
     pub query_params: Vec<(&'a str, &'a str)>,
+
+    /// Whether or not a '?' was found during parsing. If true, indicates a
+    /// '?' must be set when reconstructing.
+    pub trailing_query: bool,
+
+    /// Whether or not a trailing '&' was found during parsing. If true, indicates a
+    /// '&' must be set when reconstructing.
+    pub trailing_query_pair: bool,
 }
 
-impl<'a> RequestUrl<'a> {
-    pub fn parse(input: &'a str) -> Result<Self, nom::Err<nom::error::Error<&'a str>>> {
-        let (input, scheme) = take_while1(char::is_alphabetic)(input)?;
+type NomError<'a> = nom::Err<(&'a str, nom::error::ErrorKind)>;
 
-        let (input, _) = tag("://")(input)?;
+impl<'a> RequestUrl<'a> {
+    pub fn parse(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));
+            }
 
-        let mut path_parser = many0(preceded(char('/'), take_while(|c: char| c != '/')));
+            // Parse query
+            // First char will always be a '?' since we parsed succesfully
+            let mut query = &query[1..];
+            let mut query_params = vec![];
 
-        let mut segment_parser =
-            preceded(tag::<_, _, nom::error::Error<_>>(":"), take_while(|_| true));
+            let mut trailing_pair = false;
 
-        match take_until1::<_, _, nom::error::Error<_>>("?")(input) {
-            // URL has query parameters
-            Ok((query, path)) => {
-                // Parse query
-                // First char will always be a '?' since we parsed succesfully
-                let mut query = &query[1..];
-                let mut query_params = vec![];
+            loop {
+                if query.is_empty() {
+                    break;
+                }
 
-                loop {
-                    if query.is_empty() {
-                        break;
-                    }
+                let (i, params) = separated_pair(
+                    take_while(|c: char| c != '='),
+                    char('='),
+                    take_while(|c: char| c != '&'),
+                )
+                .parse(query)?;
 
-                    let (i, params) = separated_pair(
-                        take_while(|c: char| c != '='),
-                        char('='),
-                        take_while(|c: char| c != '&'),
-                    )
-                    .parse(query)?;
+                query = i;
+                query_params.push((params.0, params.1));
 
+                if let Ok((i, _)) = char::<_, nom::error::Error<_>>('&').parse(query) {
+                    trailing_pair = i.is_empty();
                     query = i;
-                    query_params.push((params.0, params.1));
-
-                    if let Ok((i, _)) = char::<_, nom::error::Error<_>>('&').parse(query) {
-                        query = i;
-                    }
                 }
+            }
 
-                debug_assert!(query.is_empty());
-
-                // Check path segments
-
-                match take_until::<_, _, nom::error::Error<_>>("/")(path) {
-                    // Path exists
-                    Ok((path, host)) => {
-                        let (input, segments) = path_parser.parse(path)?;
-                        debug_assert!(input.is_empty());
-                        Ok(RequestUrl {
-                            scheme,
-                            host,
-                            path: segments
-                                .into_iter()
-                                .map(|segment| {
-                                    segment_parser.parse(segment).ok().map_or(
-                                        Segment::Static(segment),
-                                        |(r, s)| {
-                                            debug_assert_eq!("", r);
-                                            Segment::Dynamic(s)
-                                        },
-                                    )
-                                })
-                                .collect(),
-                            query_params,
-                        })
-                    }
+            debug_assert!(query.is_empty());
 
-                    // No path segments
-                    Err(_) => Ok(RequestUrl {
-                        scheme,
-                        host: path,
-                        path: vec![],
-                        query_params,
-                    }),
-                }
-            }
-            // No query params
-            Err(_) => {
-                match take_until::<_, _, nom::error::Error<_>>("/")(input) {
-                    // Path exists
-                    Ok((path, host)) => {
-                        let (input, segments) = path_parser.parse(path)?;
-                        debug_assert!(input.is_empty());
-                        Ok(RequestUrl {
-                            scheme,
-                            host,
-                            path: segments
-                                .into_iter()
-                                .map(|segment| {
-                                    segment_parser.parse(segment).ok().map_or(
-                                        Segment::Static(segment),
-                                        |(r, s)| {
-                                            debug_assert!(r.is_empty());
-                                            Segment::Dynamic(s)
-                                        },
-                                    )
-                                })
-                                .collect(),
-                            query_params: vec![],
-                        })
-                    }
-                    // No path segments
-                    Err(_) => Ok(RequestUrl {
-                        scheme,
-                        host: input,
-                        path: vec![],
-                        query_params: vec![],
-                    }),
-                }
+            Ok((query_params, trailing_pair))
+        }
+
+        let (input, scheme) = match take_while1(char::is_alphabetic)(input) {
+            Ok((i, s)) => (i, s),
+            Err(e) => return Err(map_nom_err(input, None, e)),
+        };
+
+        let (input, _) = tag("://")(input).map_err(|e| map_nom_err(input, Some("://"), e))?;
+
+        // Parse until first /
+
+        let (path, host) =
+            take_while(|c| c != '/')(input).map_err(|e| map_nom_err(input, None, e))?;
+
+        // We've fully parsed the string, no path
+
+        if path.is_empty() {
+            let (query, host) =
+                take_while(|c| c != '?')(host).map_err(|e| map_nom_err(host, None, e))?;
+
+            let (query_params, trailing) =
+                parse_query(query).map_err(|e| map_nom_err(query, None, e))?;
+
+            return Ok(RequestUrl {
+                scheme,
+                host,
+                path: vec![],
+                query_params,
+                trailing_query: query == "?",
+                trailing_query_pair: trailing,
+            });
+        }
+
+        // Parse until query
+
+        let (query, path) =
+            take_while(|c| c != '?')(path).map_err(|e| map_nom_err(path, None, e))?;
+
+        let (query_params, trailing) =
+            parse_query(query).map_err(|e| map_nom_err(query, None, e))?;
+
+        let (remainder, segments) = many0(preceded(char('/'), take_while(|c| c != '/')))
+            .parse(path)
+            .map_err(|e| map_nom_err(path, None, e))?;
+
+        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(),
+            query_params,
+            trailing_query: query == "?",
+            trailing_query_pair: trailing,
+        })
+    }
+
+    pub fn populate(&mut self, path_params: HashMap<&'a str, &'a str>) {
+        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));
             }
         }
     }
@@ -167,10 +185,59 @@ impl<'a> From<RequestUrl<'a>> for RequestUrlOwned {
                 .into_iter()
                 .map(|(k, v)| (k.to_owned(), v.to_owned()))
                 .collect(),
+            has_query: value.trailing_query,
         }
     }
 }
 
+impl<'a> Display for RequestUrl<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let RequestUrl {
+            scheme,
+            host,
+            path,
+            query_params,
+            trailing_query,
+            trailing_query_pair,
+        } = self;
+
+        let path = path.iter().fold(String::new(), |mut acc, el| {
+            acc.push('/');
+            acc.push_str(&el.to_string());
+            acc
+        });
+
+        let query = if query_params.is_empty() {
+            if *trailing_query {
+                String::from("?")
+            } else {
+                String::new()
+            }
+        } else {
+            let mut params = query_params.iter().enumerate().fold(
+                String::from("?"),
+                |mut acc, (i, (key, val))| {
+                    acc.push_str(key);
+                    acc.push('=');
+                    acc.push_str(val);
+                    if i < query_params.len() - 1 {
+                        acc.push('&')
+                    }
+                    acc
+                },
+            );
+
+            if *trailing_query_pair {
+                params.push('&');
+            }
+
+            params
+        };
+
+        write!(f, "{scheme}://{host}{path}{query}")
+    }
+}
+
 #[derive(Debug, PartialEq, Eq)]
 pub enum Segment<'a> {
     /// Path segments that do not change.
@@ -183,6 +250,15 @@ pub enum Segment<'a> {
     Dynamic(&'a str),
 }
 
+impl<'a> Segment<'a> {
+    pub fn value(&self) -> &'a str {
+        match self {
+            Segment::Static(s) => s,
+            Segment::Dynamic(s) => s,
+        }
+    }
+}
+
 impl<'a> From<Segment<'a>> for SegmentOwned {
     fn from(value: Segment<'a>) -> Self {
         match value {
@@ -192,6 +268,80 @@ 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}"),
+        }
+    }
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", content = "error")]
+pub enum UrlError {
+    /// Contains the duplicate identifier
+    DuplicatePath(String),
+
+    /// Contains info about the parsing error
+    Parse(UrlParseError),
+
+    Var(String),
+
+    Db(String),
+}
+
+#[derive(Debug, Serialize)]
+pub struct UrlParseError {
+    #[serde(serialize_with = "serialize_kind")]
+    kind: Option<nom::error::ErrorKind>,
+    token: Option<String>,
+    input: String,
+    incomplete: bool,
+    recoverable: bool,
+}
+
+fn map_nom_err<'a>(
+    input: &'a str,
+    token: Option<&'a str>,
+    e: nom::Err<(&'a str, nom::error::ErrorKind)>,
+) -> UrlParseError {
+    let token = token.map(|t| t.to_string());
+    match e {
+        nom::Err::Incomplete(_) => UrlParseError {
+            kind: None,
+            token,
+            input: input.to_string(),
+            incomplete: true,
+            recoverable: true,
+        },
+        nom::Err::Error((i, e)) => UrlParseError {
+            kind: Some(e),
+            token,
+            input: i.to_string(),
+            incomplete: false,
+            recoverable: true,
+        },
+        nom::Err::Failure((i, e)) => UrlParseError {
+            kind: Some(e),
+            token,
+            input: i.to_string(),
+            incomplete: false,
+            recoverable: false,
+        },
+    }
+}
+
+fn serialize_kind<S>(kind: &Option<nom::error::ErrorKind>, serializer: S) -> Result<S::Ok, S::Error>
+where
+    S: serde::Serializer,
+{
+    match kind {
+        Some(kind) => serializer.serialize_str(kind.description()),
+        None => serializer.serialize_none(),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::{RequestUrl, Segment};
@@ -245,6 +395,30 @@ mod tests {
         assert!(url.query_params.is_empty());
     }
 
+    #[test]
+    fn parses_sequential_empty_path_segments() {
+        let input = "http://localhost:4000//";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(vec![Segment::Static(""), Segment::Static("")], url.path);
+        assert!(url.query_params.is_empty());
+    }
+
+    #[test]
+    fn parses_sequential_empty_dyn_path_segments() {
+        let input = "http://localhost:4000/:/:";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(vec![Segment::Dynamic(""), Segment::Dynamic("")], url.path);
+        assert!(url.query_params.is_empty());
+    }
+
     #[test]
     fn parse_no_path_segments_trailing_slash() {
         let input = "http://localhost:4000/";
@@ -287,4 +461,24 @@ mod tests {
         );
         assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
     }
+
+    #[test]
+    fn parse_query_params_with_path_trailing_slash() {
+        let input = "http://localhost:4000/foo/:bar/:qux/?foo=bar&baz=bax";
+
+        let url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http", url.scheme);
+        assert_eq!("localhost:4000", url.host);
+        assert_eq!(
+            vec![
+                Segment::Static("foo"),
+                Segment::Dynamic("bar"),
+                Segment::Dynamic("qux"),
+                Segment::Static("")
+            ],
+            url.path
+        );
+        assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);
+    }
 }

+ 35 - 5
src-tauri/src/var.rs

@@ -1,5 +1,10 @@
+use std::collections::HashMap;
+
 use nom::{
-    bytes::complete::{tag, take_until},
+    bytes::{
+        complete::{tag, take_until},
+        take_until1,
+    },
     sequence::delimited,
     Parser,
 };
@@ -20,6 +25,11 @@ impl<'a> From<Var<'a>> for VarOwned {
     }
 }
 
+const VAR_START: &str = "{{";
+const VAR_END: &str = "}}";
+
+/// A parsed variable. Variables are always populated before parsing [the
+/// URL][crate::request::url].
 pub struct Var<'a> {
     /// Position of the variable in the input, including the delimiter.
     pub pos: usize,
@@ -28,12 +38,19 @@ pub struct Var<'a> {
     pub name: &'a str,
 }
 
+pub fn expand_vars<'a>(input: &'a str, vars: &[(String, String)]) -> String {
+    let mut out = String::from(input);
+
+    for (key, value) in vars {
+        out = out.replace(&format!("{VAR_START}{}{VAR_END}", key), &value);
+    }
+
+    out
+}
+
 pub fn parse_vars<'a>(
     mut input: &'a str,
 ) -> Result<Vec<Var<'a>>, nom::Err<nom::error::Error<&'a str>>> {
-    const VAR_START: &str = "{{";
-    const VAR_END: &str = "}}";
-
     let mut var_parser = delimited(
         tag::<_, _, nom::error::Error<_>>(VAR_START),
         take_until("}"),
@@ -44,7 +61,12 @@ pub fn parse_vars<'a>(
     let mut offset = 0;
 
     loop {
+        dbg!(input);
         while let Ok((rest, var)) = var_parser.parse(input) {
+            if input == dbg!(rest) {
+                return Ok(vars);
+            }
+
             input = rest;
             vars.push(Var {
                 pos: offset,
@@ -53,7 +75,8 @@ pub fn parse_vars<'a>(
             offset += 4 + var.len();
         }
 
-        let Ok((var_start, consumed)) = take_until::<_, _, nom::error::Error<_>>(VAR_START)(input)
+        let Ok((var_start, consumed)) =
+            take_until1::<_, _, nom::error::Error<_>>(VAR_START).parse(input)
         else {
             return Ok(vars);
         };
@@ -68,6 +91,13 @@ pub fn parse_vars<'a>(
 mod test {
     use crate::var::parse_vars;
 
+    #[test]
+    fn parses_no_variables() {
+        let url = "http://foo.bar";
+        let result = parse_vars(url).unwrap();
+        assert!(result.is_empty());
+    }
+
     #[test]
     fn parses_variable_start() {
         let url = "{{BASE_URL}}/foo/bar";

+ 32 - 7
src-tauri/src/workspace.rs

@@ -1,15 +1,21 @@
 use serde::{Deserialize, Serialize};
 use sqlx::prelude::Type;
 
-use crate::request::WorkspaceRequest;
+use crate::{
+    db::Update,
+    request::{
+        RequestBody, RequestHeader, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
+        WorkspaceRequest,
+    },
+};
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 pub struct Workspace {
     pub id: i64,
     pub name: String,
 }
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 #[serde(tag = "type", content = "data")]
 pub enum WorkspaceEntry {
     Collection(WorkspaceEntryBase),
@@ -27,7 +33,7 @@ impl WorkspaceEntry {
 }
 
 /// Database model representation
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 pub struct WorkspaceEntryBase {
     pub id: i64,
     pub workspace_id: i64,
@@ -36,7 +42,7 @@ pub struct WorkspaceEntryBase {
     pub r#type: WorkspaceEntryType,
 }
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 pub struct WorkspaceEnvironment {
     pub id: i64,
     pub name: String,
@@ -44,7 +50,7 @@ pub struct WorkspaceEnvironment {
     pub variables: Vec<WorkspaceEnvVariable>,
 }
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
 pub struct WorkspaceEnvVariable {
     pub id: i64,
     pub env_id: i64,
@@ -54,7 +60,7 @@ pub struct WorkspaceEnvVariable {
     pub secret: bool,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Deserialize)]
 pub enum WorkspaceEntryCreate {
     Collection {
         name: String,
@@ -70,6 +76,25 @@ pub enum WorkspaceEntryCreate {
     },
 }
 
+#[derive(Debug, Deserialize)]
+pub enum WorkspaceEntryUpdate {
+    Collection(WorkspaceEntryUpdateBase),
+    Request {
+        base: WorkspaceEntryUpdateBase,
+        method: Option<String>,
+        url: Option<String>,
+        body: Option<Update<RequestBody>>,
+        headers: Option<Vec<RequestHeaderUpdate>>,
+        path_params: Option<Vec<RequestPathUpdate>>,
+    },
+}
+
+#[derive(Debug, Deserialize, Default)]
+pub struct WorkspaceEntryUpdateBase {
+    pub name: Option<String>,
+    pub parent_id: Option<Update<i64>>,
+}
+
 #[derive(Debug, Clone, Copy, Type, Serialize)]
 #[sqlx(type_name = "INTEGER")]
 pub enum WorkspaceEntryType {

+ 18 - 5
src/lib/components/Editable.svelte

@@ -5,7 +5,17 @@
   import { state as _state } from "$lib/state.svelte";
   import { tick } from "svelte";
 
-  let { value = $bindable(), onSave } = $props();
+  let {
+    value = $bindable(),
+    onSave,
+
+    // Displayed when not editing
+    display,
+  }: {
+    value: string;
+    onSave: (val: string) => void;
+    display: any;
+  } = $props();
 
   let editing = $state(false);
   let inputRef: HTMLInputElement;
@@ -29,7 +39,12 @@
   function save() {
     if (value.trim()) {
       value = value.trim();
-      onSave();
+      try {
+        onSave(value);
+      } catch (e) {
+        console.error("error while updating editable", e);
+        value = lastValue;
+      }
     }
     editing = false;
   }
@@ -59,8 +74,6 @@
       <X class="w-4 h-4" />
     </Button>
   {:else}
-    <h2 class="text-lg font-semibold cursor-pointer" ondblclick={startEdit}>
-      {value}
-    </h2>
+    {@render display({ value, startEdit })}
   {/if}
 </div>

+ 7 - 1
src/lib/components/Environment.svelte

@@ -80,7 +80,13 @@
       onSave={() => {
         updateEnvironment();
       }}
-    />
+    >
+      {#snippet display({ value, startEdit })}
+        <h2 class="text-lg font-semibold cursor-pointer" ondblclick={startEdit}>
+          {value}
+        </h2>
+      {/snippet}
+    </Editable>
 
     <!-- Variables -->
     <div class="space-y-3">

+ 110 - 54
src/lib/components/WorkspaceEntry.svelte

@@ -1,12 +1,25 @@
 <script lang="ts">
-  import { state as _state, selectEntry } from "$lib/state.svelte";
+  import {
+    state as _state,
+    expandUrl,
+    parseUrl,
+    selectEntry,
+    updateEntryName,
+    updateUrl,
+  } from "$lib/state.svelte";
   import { Button } from "$lib/components/ui/button";
   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 { RequestUrl } from "$lib/types";
+  import type {
+    PathParam,
+    PathSegment,
+    RequestUrl,
+    UrlError,
+  } from "$lib/types";
   import { onMount } from "svelte";
+  import Editable from "./Editable.svelte";
 
   let headers = [
     { key: "Authorization", value: "Bearer token" },
@@ -17,7 +30,7 @@
     "name": "John Doe"
   }`);
 
-  const referenceChain = $derived(() => {
+  const referenceChain = $derived.by(() => {
     const parents = [];
 
     let parent = _state.entry!!.parent_id;
@@ -30,73 +43,109 @@
     return parents.reverse();
   });
 
-  let urlTemplate: RequestUrl | null = $derived(
-    _state.entry.type === "Request" ? await parseUrl() : null,
-  );
-  let urlDynPaths = {};
+  let url: RequestUrl | null = $state(null);
+  let expanded: string = $state("");
 
-  async function parseUrl(): Promise<RequestUrl | null> {
-    if (!_state.entry?.url) {
-      return null;
-    }
+  async function handleUrlUpdate(direct = false) {
+    const u = direct ? _state.entry!!.url : constructUrl();
+    console.log(u);
 
     try {
-      const url = await invoke<RequestUrl>("parse_url", {
-        url: _state.entry.url,
-      });
-      for (const seg of url.path) {
-        if (seg.type === "Dynamic") {
-          urlDynPaths[seg.value] = "";
+      url = await updateUrl(u);
+      expandUrl()
+        .then((full) => {
+          console.log("expanded", full);
+          expanded = full;
+        })
+        .catch((e) => console.error(e));
+    } catch (err) {
+      console.error(err);
+      const e = err as UrlError;
+      switch (e.type) {
+        case "Parse": {
+          console.error("url parse error", e.error);
+          break;
+        }
+        case "DuplicatePath": {
+          console.error("url duplicate path error", e.error);
+        }
+        case "Db": {
+          console.error("url persist error", e.error);
+          break;
         }
       }
-      return url;
-    } catch (e) {
-      return null;
+      return;
     }
+
+    console.log("constructed URL", _state.entry.url);
   }
 
-  function constructUrl() {
-    if (!urlTemplate) {
-      return "";
+  /** 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));
+    let path = "";
+    if (_state.entry.path_params.length > 0) {
+      for (const param of _state.entry.path_params) {
+        if (param.name !== undefined) {
+          path += "/:" + param.name;
+        } else {
+          path += "/" + param.value;
+        }
+      }
     }
 
-    const path = urlTemplate.path
-      .map((s) => (s.type === "Dynamic" ? `:${s.value}` : s.value))
-      .join("/");
-
-    const query = urlTemplate.query_params.length
-      ? "?" + urlTemplate.query_params.map((p) => `${p[0]}=${p[1]}`).join("&")
-      : "";
+    let query = "";
 
-    _state.entry!!.url = `${urlTemplate.scheme}://${urlTemplate.host}/${path}${query}`;
+    if (url!!.query_params.length > 0) {
+      query += "?" + url?.query_params.map((p) => `${p[0]}=${p[1]}`).join("&");
+    } else if (url!!.has_query) {
+      query += "?";
+    }
 
-    parseUrl();
+    return `${url!!.scheme}://${url!!.host}${path}${query}`;
   }
 
   onMount(() => {
-    parseUrl();
+    if (_state.entry?.type === "Request") {
+      parseUrl()
+        .then((u) => {
+          url = u;
+        })
+        .catch((e) => console.error("error parsing url", e));
+      expandUrl().then((u) => (expanded = u));
+    }
   });
 </script>
 
 {#snippet entryPath()}
   <!-- ENTRY PATH -->
   <div class="h-8 flex items-center">
-    {#each referenceChain() as ref, i}
+    {#each referenceChain as ref}
       <Button onclick={() => selectEntry(ref.id)} variant="ghost">
         {ref.name || ref.type + "(" + ref.id + ")"}
       </Button>
       <p>/</p>
     {/each}
-    <p>
-      {_state.entry!!.name ||
-        _state.entry!!.type + "(" + _state.entry!!.id + ")"}
-    </p>
+    <Editable
+      bind:value={_state.entry!!.name}
+      onSave={(value) => {
+        updateEntryName(value);
+      }}
+    >
+      {#snippet display({ value, startEdit })}
+        <h1 ondblclick={startEdit}>
+          {value || _state.entry!!.type + "(" + _state.entry!!.id + ")"}
+        </h1>
+      {/snippet}
+    </Editable>
   </div>
 {/snippet}
 
 <main class="w-full h-full p-4 space-y-4">
   {#if _state.entry?.type === "Collection"}
     <!-- COLLECTION VIEW -->
+
     {@render entryPath()}
     <section class="space-y-4">
       <h1 class="text-xl font-semibold">{_state.entry.name}</h1>
@@ -118,20 +167,27 @@
     </section>
   {:else if _state.entry?.type === "Request"}
     <!-- REQUEST WORK AREA -->
+
+    {@render entryPath()}
+
     <section class="space-y-4">
-      {@render entryPath()}
       <!-- URL BAR -->
 
-      <div class="flex gap-2">
+      <div class="flex flex-wrap gap-3">
         <Input
-          class="flex-1 font-mono"
+          class="w-10/12 flex font-mono"
           bind:value={_state.entry.url}
           placeholder="https://api.example.com/resource"
           oninput={() => {
-            parseUrl(_state.entry.url);
+            handleUrlUpdate(true);
           }}
         />
-        <Button>Send</Button>
+
+        <Button class="w-1/12">Send</Button>
+
+        <p class="w-full pl-1 text-xs text-muted-foreground">
+          {expanded}
+        </p>
       </div>
 
       <!-- COLLAPSIBLE SECTIONS -->
@@ -143,7 +199,7 @@
       >
         <!-- URL PARAMS -->
 
-        {#if urlTemplate?.path.some((p) => p.type === "Dynamic") || urlTemplate?.query_params.length > 0}
+        {#if url?.path.some((p) => p.type === "Dynamic") || url?.query_params.length > 0}
           <Accordion.Item value="params">
             <Accordion.Trigger>Parameters</Accordion.Trigger>
 
@@ -152,22 +208,22 @@
             <Accordion.Content
               class="border flex-col justify-center items-center space-y-4"
             >
-              {#if urlTemplate?.path.some((p) => p.type === "Dynamic")}
+              {#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 urlTemplate.path.filter((p) => p.type === "Dynamic") as param}
+                    {#each _state.entry.path_params.filter((p) => p.name !== undefined) as param}
                       <Input
-                        bind:value={param.value}
+                        bind:value={param.name}
                         placeholder="key"
-                        oninput={constructUrl}
+                        oninput={() => handleUrlUpdate()}
                       />
                       <Input
-                        bind:value={urlDynPaths[param.value]}
+                        bind:value={param.value}
                         placeholder="value"
-                        oninput={constructUrl}
+                        oninput={() => handleUrlUpdate()}
                       />
                     {/each}
                   </div>
@@ -176,19 +232,19 @@
 
               <!-- QUERY PARAMS -->
 
-              {#if urlTemplate?.query_params.length > 0}
+              {#if url?.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 urlTemplate!!.query_params as param}
+                  {#each url!!.query_params as param}
                     <Input
                       bind:value={param[0]}
                       placeholder="key"
-                      oninput={constructUrl}
+                      oninput={() => handleUrlUpdate()}
                     />
                     <Input
                       bind:value={param[1]}
                       placeholder="value"
-                      oninput={constructUrl}
+                      oninput={() => handleUrlUpdate()}
                     />
                   {/each}
                 </div>

+ 9 - 1
src/lib/settings.svelte.ts

@@ -7,13 +7,20 @@ export async function init() {
   store = await load("settings.json", {
     defaults: {
       theme: "dark",
+      lastEnvironment: {},
     },
     autoSave: false,
   });
 }
+
 export type Settings = {
-  theme?: "dark" | "light";
+  theme: "dark" | "light";
   lastEntry?: WorkspaceEntry;
+
+  /**
+   * Maps workspace IDs to environment IDs.
+   */
+  lastEnvironment: Record<number, number>;
 };
 
 export async function getSetting<K extends keyof Settings>(
@@ -26,5 +33,6 @@ export async function setSetting<K extends keyof Settings>(
   key: K,
   value: Settings[K],
 ) {
+  console.debug("setting", key, value);
   return store.set(key, value);
 }

+ 102 - 5
src/lib/state.svelte.ts

@@ -6,7 +6,10 @@ import type {
   WorkspaceEntry,
   WorkspaceEnvironment,
   EnvVariable,
+  RequestUrl,
+  WorkspaceRequest,
 } from "./types";
+import { getSetting, setSetting } from "./settings.svelte";
 
 export type WorkspaceState = {
   /**
@@ -79,13 +82,21 @@ function reset() {
   state.environments = [];
 }
 
-export function selectEnvironment(id: number | null) {
+export async function selectEnvironment(id: number | null) {
   if (id === null) {
     state.environment = null;
     return;
   }
-  console.debug("selecting environment:", state.environments[id]);
   state.environment = state.environments.find((e) => e.id === id) ?? null;
+
+  let env = await getSetting("lastEnvironment");
+  if (env) {
+    env[state.workspace!!.id] = id;
+  } else {
+    env = { [state.workspace!!.id]: id };
+  }
+  setSetting("lastEnvironment", env);
+  console.debug("selected environment:", state.environment.name);
 }
 
 export function selectWorkspace(ws: Workspace) {
@@ -94,8 +105,8 @@ export function selectWorkspace(ws: Workspace) {
 }
 
 export function selectEntry(id: number) {
-  console.log("selecting entry:", id);
   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) {
@@ -138,6 +149,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,
       });
     } else {
       index(entry.data);
@@ -171,7 +183,8 @@ export function createRequest(parentId?: number) {
       method: data.Request.method,
       url: data.Request.url,
       body: null,
-      headers: {},
+      headers: [],
+      path_params: [],
     });
     console.log("request created:", entry);
   });
@@ -203,6 +216,10 @@ export async function loadEnvironments(workspaceId: number) {
     "list_environments",
     { workspaceId },
   );
+  const lastEnv = (await getSetting("lastEnvironment"))?.[workspaceId];
+  if (lastEnv) {
+    selectEnvironment(lastEnv);
+  }
 }
 
 export async function createEnvironment(workspaceId: number, name: string) {
@@ -229,6 +246,85 @@ export async function updateEnvironment() {
   });
 }
 
+export async function updateEntryName(name: string) {
+  if (!state.entry) {
+    console.warn("attempted to persist null entry");
+    return;
+  }
+
+  console.debug(state.entry.id, "updating entry name to", name);
+
+  const data =
+    state.entry.type === "Request"
+      ? {
+          Request: {
+            base: {
+              name,
+            },
+          },
+        }
+      : { Collection: { name } };
+
+  await invoke("update_workspace_entry", {
+    entryId: state.entry.id,
+    data,
+  });
+}
+
+export async function parseUrl(): Promise<RequestUrl> {
+  console.debug("parsing", $state.snapshot(state.entry!!.url));
+  return invoke<RequestUrl>("parse_url", {
+    url: state.entry!!.url,
+    envId: state.environment?.id,
+  });
+}
+
+export async function updateUrl(u: string): Promise<RequestUrl> {
+  console.debug("updating", $state.snapshot(state.entry));
+
+  const pathParams = {};
+
+  for (const p of state.entry.path_params) {
+    pathParams[p.name] = p.value;
+  }
+
+  const url = await invoke<RequestUrl>("update_url", {
+    entryId: state.entry!!.id,
+    envId: state.environment?.id,
+    url: u,
+    pathParams,
+  });
+
+  state.entry!!.url = u;
+
+  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();
+
+  return url;
+}
+
+export async function expandUrl() {
+  return invoke<string>("expand_url", {
+    entryId: state.entry!!.id,
+    envId: state.environment?.id,
+    url: state.entry!!.url,
+  });
+}
+
 export async function insertEnvVariable(
   workspaceId: number,
   envId: number,
@@ -286,5 +382,6 @@ type WorkspaceRequestResponse = {
   method: string;
   url: string;
   body: RequestBody | null;
-  headers: { [key: string]: string };
+  headers: RequestKVParam[];
+  path_params: RequestKVParam[];
 };

+ 17 - 3
src/lib/types.ts

@@ -5,6 +5,8 @@ export type Workspace = {
 
 export type WorkspaceEntryType = "Request" | "Collection";
 
+export type WorkspaceEntry = WorkspaceEntryBase | WorkspaceRequest;
+
 export type WorkspaceEntryBase = {
   // Values from models
   id: number;
@@ -21,7 +23,8 @@ export type WorkspaceRequest = WorkspaceEntryBase & {
   method: string;
   url: string;
   body: RequestBody | null;
-  headers: { [key: string]: string };
+  headers: PathParam[];
+  path: (PathParam & { current: string })[];
 };
 
 export type RequestUrl = {
@@ -29,6 +32,19 @@ export type RequestUrl = {
   host: string;
   path: PathSegment[];
   query_params: string[][];
+  has_query: boolean;
+};
+
+export type UrlErrorType = "Parse" | "DuplicatePath" | "Db";
+
+export type UrlError = {
+  type: UrlErrorType;
+  error: string;
+};
+
+export type PathParam = {
+  name: string;
+  value: string;
 };
 
 export type PathSegment = {
@@ -36,8 +52,6 @@ export type PathSegment = {
   value: string;
 };
 
-export type WorkspaceEntry = WorkspaceEntryBase | WorkspaceRequest;
-
 export type WorkspaceCreateCollection = {
   name: string;
   workspace_id: number;

+ 1 - 1
tsconfig.json

@@ -10,7 +10,7 @@
     "sourceMap": true,
     "strict": true,
     "moduleResolution": "bundler",
-    "paths": {
+    "alias": {
       "$lib": ["./src/lib"],
       "$lib/*": ["./src/lib/*"]
     }