Kaynağa Gözat

improve queries

biblius 2 hafta önce
ebeveyn
işleme
651f7bf0de

+ 3 - 0
scripts/remigrate.sh

@@ -0,0 +1,3 @@
+sqlx migrate revert --source src-tauri/migrations
+sqlx migrate run --source src-tauri/migrations
+sqlite3 rquest.db < src-tauri/seed/init.sql

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

@@ -65,11 +65,11 @@ CREATE TABLE request_path_params (
 
 CREATE TABLE request_query_params (
     id INTEGER PRIMARY KEY NOT NULL,
-    position INTEGER NOT NULL,
+    -- A non-null position means a QP is enabled
+    position INTEGER,
     request_id INTEGER NOT NULL,
     key TEXT NOT NULL,
     value TEXT NOT NULL,
-    enabled BOOLEAN NOT NULL DEFAULT TRUE,
     UNIQUE(position, request_id),
     FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
@@ -77,8 +77,8 @@ CREATE TABLE request_query_params (
 CREATE TABLE request_bodies (
     id INTEGER PRIMARY KEY NOT NULL,
     request_id UNIQUE NOT NULL,
-    content_type TEXT NOT NULL,
-    body TEXT NOT NULL,
+    ty TEXT NOT NULL,
+    content TEXT NOT NULL,
     FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 

+ 227 - 84
src-tauri/src/cmd.rs

@@ -2,17 +2,19 @@ use crate::{
     auth::{expand_auth_vars, Auth, AuthType, Authentication},
     db::{self, Update},
     request::{
-        url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
-        EntryRequestBody, HttpRequestParameters, RequestBody, RequestHeader, RequestHeaderInsert,
-        RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
+        url::{QueryParam, RequestUrl, RequestUrlOwned, Segment, UrlError},
+        EntryRequestBody, HttpRequestParameters, PathParamWrite, QueryParamRead, QueryParamWrite,
+        RequestBody, RequestHeader, RequestHeaderInsert, RequestHeaderUpdate, RequestPathUpdate,
+        RequestQueryUpdate, RequestUrlParams,
     },
     state::{AppState, ResponseResult},
     var::{expand_vars, parse_vars},
     workspace::{
-        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryUpdate,
-        WorkspaceEntryUpdateBase, WorkspaceEnvVariable, WorkspaceEnvironment,
+        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEnvVariable,
+        WorkspaceEnvironment,
     },
 };
+use serde::Deserialize;
 use tauri::ipc::Channel;
 use tauri_plugin_log::log;
 
@@ -72,9 +74,9 @@ pub async fn create_workspace_entry(
 pub async fn update_workspace_entry(
     state: tauri::State<'_, AppState>,
     entry_id: i64,
-    data: WorkspaceEntryUpdate,
+    name: String,
 ) -> Result<(), String> {
-    match db::update_workspace_entry(state.db.clone(), entry_id, data).await {
+    match db::update_entry_name(state.db.clone(), entry_id, &name).await {
         Ok(()) => Ok(()),
         Err(e) => Err(e.to_string()),
     }
@@ -175,94 +177,241 @@ pub async fn expand_url(
     }
 }
 
+#[tauri::command]
+pub async fn update_query_param_enabled(
+    state: tauri::State<'_, AppState>,
+    req_id: i64,
+    qp_id: i64,
+    url: String,
+) -> Result<(QueryParamRead, String), String> {
+    match RequestUrl::parse(&url) {
+        Ok(mut url) => {
+            let qp = db::get_query_param(&state.db, qp_id)
+                .await
+                .map_err(|e| e.to_string())?;
+
+            log::debug!("Updating {qp:?}");
+
+            if let Some(position) = qp.position {
+                dbg!(position, &url);
+
+                url.remove_query_param(position as usize);
+
+                db::update_request_url(&state.db, req_id, &url.to_string(), None, None)
+                    .await
+                    .map_err(|e| e.to_string())?;
+
+                let qp = db::update_query_param_enabled(&state.db, qp_id, None)
+                    .await
+                    .map_err(|e| e.to_string())?;
+
+                log::debug!("Updated {qp:?}");
+
+                Ok((qp, url.to_string()))
+            } else {
+                let position = url.add_qp_clear_trail(&qp.key, &qp.value);
+
+                db::update_request_url(&state.db, req_id, &url.to_string(), None, None)
+                    .await
+                    .map_err(|e| e.to_string())?;
+
+                let qp = db::update_query_param_enabled(&state.db, qp_id, Some(position as i64))
+                    .await
+                    .map_err(|e| e.to_string())?;
+
+                log::debug!("Updated {qp:?}");
+
+                Ok((qp, url.to_string()))
+            }
+        }
+        Err(e) => {
+            log::error!("{e:?}");
+            Err(e.to_string())
+        }
+    }
+}
+
+#[derive(Debug, Deserialize)]
+pub enum UrlUpdate {
+    /// URL update coming straight from the URL string.
+    URL(String),
+    Path(String, PathParamWrite),
+    Query(String, QueryParamWrite),
+}
+
 /// Updates a URL.
 ///
 /// * `entry_id`: The request entry ID  whose URL will be updated.
-/// * `env_id`: The environment to use for expanding variables.
-/// * `use_path_params: Set to true when the user edits the path param from the `Parameters`
-/// section. Set to false when the user edits the path key directly in the URL.
-/// * `url`: The URL string which will be populated from the env and parsed with [RequestUrl].
-/// * `path_params`: List of dynamic path params to persist for the URL.
+/// * `update`: Update payload.
 #[tauri::command]
 pub async fn update_url(
     state: tauri::State<'_, AppState>,
     entry_id: i64,
-    use_path_params: bool,
-    url: String,
-    // Dynamic path params
-    path_params: Vec<RequestPathParam>,
-) -> Result<(RequestUrlOwned, Vec<RequestPathParam>), UrlError> {
-    match RequestUrl::parse(&url) {
-        Ok(mut url_parsed) => {
-            let mut update: Vec<RequestPathUpdate> = vec![];
-            let mut subs = vec![];
-
-            for seg in url_parsed.path.iter_mut() {
-                let Segment::Dynamic(seg, position) = seg else {
-                    continue;
-                };
-
-                let Some(path_param) = path_params
-                    .iter()
-                    .find(|pp| pp.position as usize == *position || &pp.name == seg)
-                else {
-                    // A new path param is being added
-                    update.push(RequestPathUpdate {
-                        position: *position,
-                        name: seg.to_string(),
+    update: UrlUpdate,
+) -> Result<RequestUrlParams, UrlError> {
+    match update {
+        // URL was edited in the URL section
+        UrlUpdate::URL(url) => match RequestUrl::parse(&url) {
+            Ok(mut url) => {
+                let mut path_update: Vec<RequestPathUpdate> = vec![];
+                let mut query_update: Vec<RequestQueryUpdate> = vec![];
+
+                for seg in url.path.iter_mut() {
+                    let Segment::Dynamic(seg, position) = seg else {
+                        continue;
+                    };
+
+                    path_update.push(RequestPathUpdate {
+                        position: *position as i64,
+                        name: seg,
+                        // Values are coalesced and the value cannot be updated
+                        // from the URL bar
                         value: None,
                     });
-                    continue;
-                };
+                }
 
-                // The path was edited from the parameters section
-                if use_path_params {
-                    update.push(RequestPathUpdate {
-                        position: *position,
-                        name: path_param.name.clone(),
-                        value: Some(path_param.value.clone()),
+                for qp in url.query_params.iter() {
+                    query_update.push(RequestQueryUpdate {
+                        position: qp.position as i64,
+                        key: qp.key,
+                        value: qp.value,
                     });
-                    subs.push(Segment::Dynamic(&path_param.name, *position));
-                } else {
-                    // The path was edited in the URL section
-                    update.push(RequestPathUpdate {
-                        position: *position,
-                        name: seg.to_string(),
-                        value: Some(path_param.value.clone()),
-                    })
                 }
-            }
 
-            for sub in subs {
-                url_parsed.swap_path_segment(sub);
+                db::update_request_url(
+                    &state.db,
+                    entry_id,
+                    &url.to_string(),
+                    Some(path_update),
+                    Some(query_update),
+                )
+                .await
+                .map_err(|e| UrlError::Db(e.to_string()))?;
+
+                let params = db::get_request_url_params(&state.db, entry_id)
+                    .await
+                    .map_err(|e| UrlError::Db(e.to_string()))?;
+
+                Ok(RequestUrlParams {
+                    full: url.to_string(),
+                    path: params.0,
+                    query: params.1,
+                })
             }
+            Err(e) => {
+                log::debug!("{e:?}");
+                return Err(UrlError::Parse(e));
+            }
+        },
+        // URL was edited via path parameters
+        UrlUpdate::Path(url, path_param) => match RequestUrl::parse(&url) {
+            Ok(mut url) => {
+                // When editing from the params section, positions do not change
+                url.swap_path_segment(Segment::Dynamic(
+                    &path_param.name,
+                    path_param.position as usize,
+                ));
+
+                // Update adjusted positions
+
+                let path_update = url
+                    .path
+                    .iter()
+                    .filter_map(|segment| {
+                        let Segment::Dynamic(seg, pos) = segment else {
+                            return None;
+                        };
+
+                        let pos = *pos as i64;
+
+                        Some(RequestPathUpdate {
+                            position: pos,
+                            name: seg,
+                            value: (path_param.position == pos).then_some(&path_param.value),
+                        })
+                    })
+                    .collect();
 
-            db::update_workspace_entry(
-                state.db.clone(),
-                entry_id,
-                WorkspaceEntryUpdate::Request {
-                    // TODO:
-                    query_params: None,
-                    path_params: Some(update),
-                    url: Some(url_parsed.to_string()),
-                    base: WorkspaceEntryUpdateBase::default(),
-                    method: None,
-                },
-            )
-            .await
-            .map_err(|e| UrlError::Db(e.to_string()))?;
+                let query_update = url
+                    .query_params
+                    .iter()
+                    .map(|qp| RequestQueryUpdate {
+                        position: qp.position as i64,
+                        key: qp.key,
+                        value: qp.value,
+                    })
+                    .collect();
+
+                db::update_request_url(
+                    &state.db,
+                    entry_id,
+                    &url.to_string(),
+                    Some(path_update),
+                    Some(query_update),
+                )
+                .await
+                .map_err(|e| UrlError::Db(e.to_string()))?;
+
+                let params = db::get_request_url_params(&state.db, entry_id)
+                    .await
+                    .map_err(|e| UrlError::Db(e.to_string()))?;
 
-            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(RequestUrlParams {
+                    full: url.to_string(),
+                    path: params.0,
+                    query: params.1,
+                })
+            }
+            Err(e) => {
+                log::debug!("{e:?}");
+                return Err(UrlError::Parse(e));
+            }
+        },
+        // URL was edited via query parameters
+        UrlUpdate::Query(url, query_param) => match RequestUrl::parse(&url) {
+            Ok(mut url) => {
+                // When editing from the params section, positions do not change
+                url.swap_query_param(QueryParam {
+                    key: &query_param.key,
+                    value: &query_param.value,
+                    position: query_param.position as usize,
+                });
+
+                let query_update = url
+                    .query_params
+                    .iter()
+                    .map(|qp| RequestQueryUpdate {
+                        position: qp.position as i64,
+                        key: qp.key,
+                        value: qp.value,
+                    })
+                    .collect();
+
+                db::update_request_url(
+                    &state.db,
+                    entry_id,
+                    &url.to_string(),
+                    None,
+                    Some(query_update),
+                )
+                .await
+                .map_err(|e| UrlError::Db(e.to_string()))?;
+
+                let params = db::get_request_url_params(&state.db, entry_id)
+                    .await
+                    .map_err(|e| UrlError::Db(e.to_string()))?;
 
-            Ok((url_parsed.into(), params))
-        }
-        Err(e) => {
-            log::debug!("{e:?}");
-            Err(UrlError::Parse(e))
-        }
+                Ok(RequestUrlParams {
+                    full: url.to_string(),
+                    path: params.0,
+                    query: params.1,
+                })
+            }
+            Err(e) => {
+                log::debug!("{e:?}");
+                return Err(UrlError::Parse(e));
+            }
+        },
     }
 }
 
@@ -383,12 +532,6 @@ pub async fn send_request(
         .await
         .map_err(|e| e.to_string())?;
 
-    // let response = match request::send(state.client.clone(), req).await {
-    //     Ok(res) => res,
-    //     Err(e) => return Err(e.to_string()),
-    // };
-
-    // Ok(response)
     Ok(())
 }
 

+ 230 - 187
src-tauri/src/db.rs

@@ -2,19 +2,23 @@ use crate::{
     auth::{Auth, Authentication},
     error::AppError,
     request::{
-        EntryRequestBody, RequestBody, RequestHeader, RequestHeaderInsert, RequestHeaderUpdate,
-        RequestParams, RequestPathParam, RequestQueryParam, WorkspaceRequest,
+        EntryRequestBody, PathParamRead, PathParamWrite, QueryParamRead, RequestBody,
+        RequestHeader, RequestHeaderInsert, RequestHeaderUpdate, RequestParams, RequestPathUpdate,
+        RequestQueryUpdate, WorkspaceRequest,
     },
     workspace::{
         Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryType,
-        WorkspaceEntryUpdate, WorkspaceEnvVariable, WorkspaceEnvironment,
+        WorkspaceEnvVariable, WorkspaceEnvironment,
     },
     AppResult,
 };
 use serde::Deserialize;
-use sqlx::{sqlite::SqlitePool, types::Json, QueryBuilder};
-use std::collections::HashMap;
-use tauri_plugin_log::log;
+use sqlx::{
+    sqlite::{SqliteConnectOptions, SqlitePool},
+    types::Json,
+    ConnectOptions, QueryBuilder,
+};
+use std::{collections::HashMap, str::FromStr};
 
 /// Used in update DTOs for **optional** properties. 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)]
@@ -44,7 +48,11 @@ impl<T: Copy> Update<T> {
 }
 
 pub async fn init(url: &str) -> SqlitePool {
-    let pool = SqlitePool::connect(url)
+    let mut opts = SqliteConnectOptions::from_str(url).unwrap();
+
+    opts = ConnectOptions::log_statements(opts, tauri_plugin_log::log::LevelFilter::Off);
+
+    let pool = SqlitePool::connect_with(opts)
         .await
         .expect("error while connecting to db");
 
@@ -184,7 +192,7 @@ pub async fn insert_request_body(
     entry_id: i64,
     body: RequestBody,
 ) -> AppResult<EntryRequestBody> {
-    Ok(sqlx::query_as!(EntryRequestBody, r#"INSERT INTO request_bodies(request_id, content_type, body) VALUES (?, ?, ?) RETURNING id, content_type AS "content_type: _", body"#, entry_id, body.ty, body.content).fetch_one(&db).await?)
+    Ok(sqlx::query_as!(EntryRequestBody, r#"INSERT INTO request_bodies(request_id, ty, content) VALUES (?, ?, ?) RETURNING id, ty AS "ty: _", content"#, entry_id, body.ty, body.content).fetch_one(&db).await?)
 }
 
 pub async fn update_request_body(
@@ -195,7 +203,7 @@ pub async fn update_request_body(
     match body {
         Update::Value(body) => {
             sqlx::query!(
-                "UPDATE request_bodies SET content_type = ?, body = ? WHERE id = ?",
+                "UPDATE request_bodies SET ty = ?, content = ? WHERE id = ?",
                 body.ty,
                 body.content,
                 id,
@@ -212,188 +220,225 @@ pub async fn update_request_body(
     Ok(())
 }
 
-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 ");
+pub async fn update_request_method(db: SqlitePool, request_id: i64, method: &str) -> AppResult<()> {
+    sqlx::query!(
+        "UPDATE request_params SET method = ? WHERE request_id = ?",
+        method,
+        request_id
+    )
+    .execute(&db)
+    .await?;
 
-            if let Some(parent) = update.parent_id {
-                check_parent(&db, parent.value()).await?;
-            }
+    Ok(())
+}
 
-            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);
-                }
-            }
+pub async fn get_query_param(db: &SqlitePool, qp_id: i64) -> AppResult<QueryParamRead> {
+    Ok(sqlx::query_as!(
+        QueryParamRead,
+        "SELECT id, position, key, value FROM request_query_params WHERE id = ?",
+        qp_id
+    )
+    .fetch_one(db)
+    .await?)
+}
 
-            sql.push("WHERE id = ")
-                .push_bind(entry_id)
-                .build()
-                .execute(&db)
-                .await?;
+pub async fn update_query_param_enabled(
+    db: &SqlitePool,
+    qp_id: i64,
+    position: Option<i64>,
+) -> AppResult<QueryParamRead> {
+    Ok(sqlx::query_as!(
+        QueryParamRead,
+        "UPDATE request_query_params SET position = ? WHERE id = ? RETURNING id, position, key, value",
+        position,
+        qp_id
+    )
+    .fetch_one(db)
+    .await?)
+}
 
-            Ok(())
-        }
-        WorkspaceEntryUpdate::Request {
-            base,
-            method,
-            url,
-            path_params,
-            query_params,
-        } => {
-            let mut tx = db.begin().await?;
+/// Return only the active request params.
+pub async fn get_request_url_params(
+    db: &SqlitePool,
+    request_id: i64,
+) -> AppResult<(Vec<PathParamRead>, Vec<QueryParamRead>)> {
+    let mut path = vec![];
+    let mut query = vec![];
 
-            'entry: {
-                if let Some(parent) = base.parent_id {
-                    check_parent(&db, parent.value()).await?;
-                }
+    let params = sqlx::query!(
+        r#"
+        SELECT id, position, name, value, 0 AS type
+        FROM request_path_params WHERE request_id = ?
+        UNION
+        SELECT id, position, key AS "name", value, 1 AS type
+        FROM request_query_params WHERE request_id = ? AND position IS NOT NULL
+      "#,
+        request_id,
+        request_id
+    )
+    .fetch_all(db)
+    .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);
-                    }
-                }
+    for param in params {
+        match param.r#type {
+            0 => path.push(PathParamRead::new(
+                param.id,
+                // Path positions can never be null
+                param.position.unwrap(),
+                param.name,
+                param.value,
+            )),
+            1 => query.push(QueryParamRead::new(
+                param.id,
+                param.position,
+                param.name,
+                param.value,
+            )),
+            _ => unreachable!(),
+        }
+    }
 
-                sql.push("WHERE id = ")
-                    .push_bind(entry_id)
-                    .build()
-                    .execute(&mut *tx)
-                    .await?;
-            };
+    Ok((path, query))
+}
 
-            '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);
-                    }
-                }
+pub async fn update_request_url(
+    db: &SqlitePool,
+    request_id: i64,
+    url: &str,
+    path_params: Option<Vec<RequestPathUpdate<'_>>>,
+    query_params: Option<Vec<RequestQueryUpdate<'_>>>,
+) -> AppResult<()> {
+    let mut tx = db.begin().await?;
 
-                sql.push("WHERE request_id = ")
-                    .push_bind(entry_id)
-                    .build()
-                    .execute(&mut *tx)
-                    .await?;
-            };
+    sqlx::query!(
+        "UPDATE request_params SET url = ? WHERE request_id = ?",
+        url,
+        request_id
+    )
+    .execute(&mut *tx)
+    .await?;
 
-            if let Some(path_params) = path_params {
-                if path_params.is_empty() {
-                    sqlx::query!(
-                        "DELETE FROM request_path_params WHERE request_id = ?",
-                        entry_id
-                    )
-                    .execute(&mut *tx)
-                    .await?;
-                } else {
-                    let mut sql = QueryBuilder::new(
-                        "INSERT INTO request_path_params(position, request_id, name, value) ",
-                    );
-
-                    sql.push_values(path_params.iter(), |mut b, path| {
-                        b.push_bind(path.position as i64)
-                            .push_bind(entry_id)
-                            .push_bind(&path.name);
-
-                        if let Some(ref path) = path.value {
-                            b.push_bind(path);
-                        } else {
-                            b.push_bind("");
-                        }
-                    });
-
-                    sql.push(
-                        r#"
-                        ON CONFLICT(position, request_id) DO UPDATE 
-                        SET 
-                            value = excluded.value,
-                            name = excluded.name;
-
-                        DELETE FROM request_path_params 
-                        WHERE request_id = "#,
+    if let Some(path_params) = path_params {
+        // Empty path params means delete everything since they cannot be toggled
+        // and their position is ALWAYS unique
+        if path_params.is_empty() {
+            sqlx::query!(
+                "DELETE FROM request_path_params WHERE request_id = ?",
+                request_id
+            )
+            .execute(&mut *tx)
+            .await?;
+        } else {
+            let mut sql = QueryBuilder::new(
+                "INSERT INTO request_path_params(position, request_id, name, value) ",
+            );
+
+            sql.push_values(path_params.iter(), |mut b, path| {
+                b.push_bind(path.position as i64)
+                    .push_bind(request_id)
+                    .push_bind(path.name)
+                    .push("COALESCE(")
+                    .push_bind_unseparated(path.value)
+                    .push_unseparated(
+                        ", (SELECT value FROM request_path_params WHERE request_id = ",
                     )
-                    .push_bind(entry_id)
-                    .push(" AND position NOT IN (");
+                    .push_bind_unseparated(request_id)
+                    .push_unseparated("AND position = ")
+                    .push_bind_unseparated(path.position as i64)
+                    .push_unseparated("), '')");
+            });
+
+            // Delete any conflicting positions
 
-                    let mut sep = sql.separated(", ");
+            sql.push(
+                r#"
+                ON CONFLICT(position, request_id) DO UPDATE 
+                SET 
+                    value = excluded.value,
+                    name = excluded.name;
 
-                    for param in path_params.iter() {
-                        sep.push_bind(param.position as i64);
-                    }
+                DELETE FROM request_path_params 
+                WHERE request_id = "#,
+            )
+            .push_bind(request_id)
+            .push(" AND position NOT IN (");
 
-                    sep.push_unseparated(")");
+            let mut sep = sql.separated(", ");
 
-                    sql.build().execute(&mut *tx).await?;
-                }
+            for param in path_params.iter() {
+                sep.push_bind(param.position as i64);
             }
 
-            tx.commit().await?;
+            sep.push_unseparated(")");
 
-            Ok(())
+            sql.build().execute(&mut *tx).await?;
         }
     }
+
+    if let Some(query_params) = query_params {
+        // Query param updates consider only the enabled QPs since toggling any
+        // disabled ones always adds them to the end of the list
+
+        if query_params.is_empty() {
+            sqlx::query!(
+                "DELETE FROM request_query_params WHERE request_id = ? AND position IS NOT NULL",
+                request_id
+            )
+            .execute(&mut *tx)
+            .await?;
+        } else {
+            let mut sql = QueryBuilder::new(
+                "INSERT INTO request_query_params(position, request_id, key, value) ",
+            );
+
+            sql.push_values(query_params.iter(), |mut b, qp| {
+                b.push_bind(qp.position as i64)
+                    .push_bind(request_id)
+                    .push_bind(qp.key)
+                    .push_bind(qp.value);
+            });
+
+            // Query params are unique by position and req_id
+
+            sql.push(
+                r#"
+                ON CONFLICT(position, request_id) DO UPDATE 
+                SET 
+                    value = excluded.value,
+                    key = excluded.key;
+
+                DELETE FROM request_query_params 
+                WHERE request_id = "#,
+            )
+            .push_bind(request_id)
+            .push(" AND position IS NOT NULL AND position NOT IN (");
+
+            let mut sep = sql.separated(", ");
+
+            for param in query_params.iter() {
+                sep.push_bind(param.position as i64);
+            }
+
+            sep.push_unseparated(")");
+
+            sql.build().execute(&mut *tx).await?;
+        }
+    }
+
+    tx.commit().await?;
+
+    Ok(())
+}
+
+pub async fn update_entry_name(db: SqlitePool, entry_id: i64, name: &str) -> AppResult<()> {
+    sqlx::query!(
+        "UPDATE workspace_entries SET name = ? WHERE id = ?",
+        name,
+        entry_id
+    )
+    .execute(&db)
+    .await?;
+    Ok(())
 }
 
 pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<WorkspaceRequest> {
@@ -412,8 +457,8 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
             rp.request_id as id,
             method as 'method!',
             url as 'url!',
-            content_type as "content_type: _",
-            body AS "body: _",
+            rb.ty as "ty: _",
+            rb.content AS "content: _",
             rb.id AS "body_id: _"
            FROM request_params rp
            LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
@@ -433,16 +478,16 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
     .await?;
 
     let path_params = sqlx::query_as!(
-        RequestPathParam,
-        "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
+        PathParamRead,
+        "SELECT id, position, name, value FROM request_path_params WHERE request_id = ?",
         entry.id
     )
     .fetch_all(&db)
     .await?;
 
     let query_params = sqlx::query_as!(
-        RequestQueryParam,
-        "SELECT position, key, value, enabled FROM request_query_params WHERE request_id = ?",
+        QueryParamRead,
+        "SELECT id, position, key, value FROM request_query_params WHERE request_id = ?",
         entry.id
     )
     .fetch_all(&db)
@@ -480,8 +525,8 @@ pub async fn get_workspace_entry(db: SqlitePool, id: i64) -> AppResult<Workspace
                    rp.request_id as id,
                    method as 'method!',
                    url as 'url!',
-                   content_type as "content_type: _",
-                   body as "body: _",
+                   ty as "ty: _",
+                   content as "content: _",
                    rb.id as "body_id: _"
                   FROM request_params rp
                   LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
@@ -493,8 +538,6 @@ pub async fn get_workspace_entry(db: SqlitePool, id: i64) -> AppResult<Workspace
             .fetch_one(&db)
             .await?;
 
-            dbg!(&params);
-
             let headers = sqlx::query_as!(
                 RequestHeader,
                 "SELECT id, name, value FROM request_headers WHERE request_id = ?",
@@ -504,16 +547,16 @@ pub async fn get_workspace_entry(db: SqlitePool, id: i64) -> AppResult<Workspace
             .await?;
 
             let path_params = sqlx::query_as!(
-                RequestPathParam,
-                "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
+                PathParamRead,
+                "SELECT id, position, name, value FROM request_path_params WHERE request_id = ?",
                 entry.id
             )
             .fetch_all(&db)
             .await?;
 
             let query_params = sqlx::query_as!(
-                RequestQueryParam,
-                "SELECT position, key, value, enabled FROM request_query_params WHERE request_id = ?",
+                QueryParamRead,
+                "SELECT id, position, key, value FROM request_query_params WHERE request_id = ?",
                 entry.id
             )
             .fetch_all(&db)
@@ -730,9 +773,9 @@ pub async fn delete_env_var(db: SqlitePool, id: i64) -> AppResult<()> {
     Ok(())
 }
 
-pub async fn list_request_path_params(db: SqlitePool, id: i64) -> AppResult<Vec<RequestPathParam>> {
+pub async fn list_request_path_params(db: SqlitePool, id: i64) -> AppResult<Vec<PathParamWrite>> {
     Ok(sqlx::query_as!(
-        RequestPathParam,
+        PathParamWrite,
         "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
         id
     )

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

@@ -61,6 +61,7 @@ pub fn run() {
             cmd::update_auth,
             cmd::rename_auth,
             cmd::delete_auth,
+            cmd::update_query_param_enabled
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 89 - 27
src-tauri/src/request.rs

@@ -1,12 +1,15 @@
 pub mod ctype;
 pub mod url;
 
-use std::str::FromStr;
+use std::{collections::HashMap, str::FromStr};
 
 use crate::{
     auth::{Auth, BasicAuth, OAuth},
     error::AppError,
-    request::{ctype::ContentType, url::RequestUrl},
+    request::{
+        ctype::ContentType,
+        url::{RequestUrl, RequestUrlOwned, Segment},
+    },
     workspace::WorkspaceEntryBase,
     AppResult,
 };
@@ -134,10 +137,10 @@ pub struct WorkspaceRequest {
     pub headers: Vec<RequestHeader>,
 
     /// URL path keys => values.
-    pub path_params: Vec<RequestPathParam>,
+    pub path_params: Vec<PathParamRead>,
 
     /// URL query params.
-    pub query_params: Vec<RequestQueryParam>,
+    pub query_params: Vec<QueryParamRead>,
 }
 
 impl WorkspaceRequest {
@@ -145,14 +148,14 @@ impl WorkspaceRequest {
         entry: WorkspaceEntryBase,
         params: RequestParams,
         headers: Vec<RequestHeader>,
-        path_params: Vec<RequestPathParam>,
-        query_params: Vec<RequestQueryParam>,
+        path_params: Vec<PathParamRead>,
+        query_params: Vec<QueryParamRead>,
     ) -> Self {
-        let body = match (params.body_id, params.body, params.content_type) {
+        let body = match (params.body_id, params.content, params.ty) {
             (Some(id), Some(body), Some(content_type)) => Some(EntryRequestBody {
                 id,
-                body,
-                content_type,
+                content: body,
+                ty: content_type,
             }),
             (None, None, None) => None,
             _ => panic!("id, body and content_type must all be present"),
@@ -249,8 +252,8 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
             method,
             headers,
             body: value.body.map(|body| RequestBody {
-                content: body.body,
-                ty: body.content_type,
+                content: body.content,
+                ty: body.ty,
             }),
         })
     }
@@ -348,39 +351,98 @@ pub struct RequestParams {
     pub id: i64,
     pub method: String,
     pub url: String,
-    pub content_type: Option<ContentType>,
-    pub body: Option<String>,
+    pub ty: Option<ContentType>,
+    pub content: Option<String>,
     pub body_id: Option<i64>,
 }
 
+#[derive(Debug, Serialize)]
+pub struct RequestUrlParams {
+    pub full: String,
+    pub path: Vec<PathParamRead>,
+    pub query: Vec<QueryParamRead>,
+}
+
 #[derive(Debug, Deserialize, Serialize)]
-pub struct RequestPathParam {
+pub struct PathParamRead {
+    pub id: i64,
     pub position: i64,
     pub name: String,
     pub value: String,
 }
 
+impl PathParamRead {
+    pub fn new(id: i64, position: i64, name: String, value: String) -> Self {
+        Self {
+            id,
+            position,
+            name,
+            value,
+        }
+    }
+}
+
 #[derive(Debug, Deserialize, Serialize)]
-pub struct RequestQueryParam {
+pub struct PathParamWrite {
     pub position: i64,
+    pub name: String,
+    pub value: String,
+}
+
+/// A query parameter for reading.
+#[derive(Debug, Serialize)]
+pub struct QueryParamRead {
+    pub id: i64,
+    /// Query params without positions are considered inactive.
+    /// If present, a QP position is always unique in the scope of a single request.
+    pub position: Option<i64>,
     pub key: String,
     pub value: String,
-    pub enabled: bool,
 }
 
+impl QueryParamRead {
+    pub fn new(id: i64, position: Option<i64>, key: String, value: String) -> Self {
+        Self {
+            id,
+            position,
+            key,
+            value,
+        }
+    }
+}
+
+/// A query parameter for writing.
 #[derive(Debug, Deserialize)]
-pub struct RequestPathUpdate {
-    pub position: usize,
-    pub name: String,
-    pub value: Option<String>,
+pub struct QueryParamWrite {
+    pub position: i64,
+    pub key: String,
+    pub value: String,
+}
+
+impl QueryParamWrite {
+    pub fn new(position: i64, key: String, value: String) -> Self {
+        Self {
+            position,
+            key,
+            value,
+        }
+    }
 }
 
+#[derive(Debug, Deserialize)]
+pub struct RequestPathUpdate<'a> {
+    pub position: i64,
+    pub name: &'a str,
+    pub value: Option<&'a str>,
+}
+
+/// Used to insert QPs.
+/// Updates only enabled QPs, i.e. those with position non-null.
 #[derive(Debug, Deserialize, Serialize)]
-pub struct RequestQueryUpdate {
-    pub position: usize,
-    pub key: String,
-    pub value: Option<String>,
-    pub enabled: Option<bool>,
+pub struct RequestQueryUpdate<'a> {
+    pub position: i64,
+    pub key: &'a str,
+    pub value: &'a str,
 }
 
 #[derive(Debug, Serialize, FromRow)]
@@ -412,6 +474,6 @@ pub struct RequestBody {
 #[derive(Debug, Serialize)]
 pub struct EntryRequestBody {
     pub id: i64,
-    pub body: String,
-    pub content_type: ContentType,
+    pub content: String,
+    pub ty: ContentType,
 }

+ 254 - 38
src-tauri/src/request/url.rs

@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
 use std::{collections::HashMap, fmt::Display};
 use tauri_plugin_log::log;
 
+/// Owned version of [RequestUrl].
 #[derive(Debug, Serialize, Deserialize)]
 pub struct RequestUrlOwned {
     pub pre: String,
@@ -19,6 +20,7 @@ pub struct RequestUrlOwned {
     pub trail: String,
 }
 
+/// Owned version of [Segment].
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(tag = "type", content = "value")]
 pub enum SegmentOwned {
@@ -26,11 +28,12 @@ pub enum SegmentOwned {
     Dynamic(String, usize),
 }
 
+/// Owned version of [QueryParam].
 #[derive(Debug, Serialize, Deserialize)]
 pub struct QueryParamOwned {
     pub key: String,
     pub value: String,
-    pub pos: usize,
+    pub position: usize,
 }
 
 /// A fully deconstructed URL from a workspace request.
@@ -72,8 +75,8 @@ impl<'a> RequestUrl<'a> {
 
                 // If no path segments, host contains query params if any,
                 // otherwise path contains it.
-                let (path, host) =
-                    take_while(|c| c != '/')(rest).map_err(|e| map_nom_err(input, None, e))?;
+                let (path, host) = take_while(|c| c != '/' && c != '?')(rest)
+                    .map_err(|e| map_nom_err(input, None, e))?;
 
                 if path.is_empty() {
                     let (query, query_pre) =
@@ -96,8 +99,8 @@ impl<'a> RequestUrl<'a> {
                 (path, &input[..offset])
             }
             Err(_) => {
-                let (path, pre) =
-                    take_while(|c| c != '/')(input).map_err(|e| map_nom_err(input, None, e))?;
+                let (path, pre) = take_while(|c| c != '/' && c != '?')(input)
+                    .map_err(|e| map_nom_err(input, None, e))?;
 
                 if path.is_empty() {
                     let (query, pre) =
@@ -160,6 +163,8 @@ impl<'a> RequestUrl<'a> {
             offset += 1;
         }
 
+        debug_assert_eq!(query, &input[offset..]);
+
         let (query_params, trail) = QueryParam::parse(query, offset);
 
         Ok(RequestUrl {
@@ -204,6 +209,14 @@ impl<'a> RequestUrl<'a> {
                 }
             }
         }
+
+        for qp in self.query_params.iter_mut() {
+            if total_displaced < 0 {
+                qp.position = qp.position - total_displaced.abs() as usize;
+            } else {
+                qp.position = qp.position + total_displaced as usize;
+            }
+        }
     }
 
     /// Swap the path segment at `new`'s position with it and adjust subsequent offsets.
@@ -233,6 +246,14 @@ impl<'a> RequestUrl<'a> {
                 path.set_position(path.position() + offset as usize);
             }
         }
+
+        for qp in self.query_params.iter_mut() {
+            if offset < 0 {
+                qp.position = qp.position - offset.abs() as usize;
+            } else {
+                qp.position = qp.position + offset as usize;
+            }
+        }
     }
 
     pub fn swap_query_param(&mut self, new: QueryParam<'a>) {
@@ -241,11 +262,11 @@ impl<'a> RequestUrl<'a> {
             .iter_mut()
             .enumerate()
             .map(|(i, qp)| (i + 1, qp))
-            .find(|(_, qp)| qp.pos == new.pos)
+            .find(|(_, qp)| qp.position == new.position)
         else {
             log::warn!(
                 "Attempted to swap query param with invalid position {}",
-                new.pos
+                new.position
             );
             return;
         };
@@ -256,27 +277,64 @@ impl<'a> RequestUrl<'a> {
 
         for qp in self.query_params.iter_mut().skip(skip) {
             if offset < 0 {
-                qp.pos = qp.pos - offset.abs() as usize;
+                qp.position = qp.position - offset.abs() as usize;
             } else {
-                qp.pos = qp.pos + offset as usize;
+                qp.position = qp.position + offset as usize;
             }
         }
     }
 
-    pub fn add_qp_clear_trail(&mut self, key: &'a str, value: &'a str) {
+    /// Add a query param to this URL's query params and return its position.
+    /// Since having trailing unparsed stuff would be confusing, clears the URL
+    /// trail as well.
+    pub fn add_qp_clear_trail(&mut self, key: &'a str, value: &'a str) -> usize {
+        self.trail = "";
+
         if let Some(last) = self.query_params.last() {
-            // +1 for the =, +1 for the &
-            let pos = last.key.len() + 2 + last.value.len();
-            self.query_params.push(QueryParam { key, value, pos });
+            // +1 for the &
+            let position = last.position + last.total_len() + 1;
+            self.query_params.push(QueryParam {
+                key,
+                value,
+                position,
+            });
+            position
         } else {
             // +1 for the ?
-            let mut pos = self.pre.len() + 1;
+            let mut position = self.pre.len() + 1;
             for path in self.path.iter() {
-                pos += path.total_len();
+                position += path.total_len();
             }
-            self.query_params.push(QueryParam { key, value, pos });
+            self.query_params.push(QueryParam {
+                key,
+                value,
+                position,
+            });
+            position
         }
-        self.trail = "";
+    }
+
+    pub fn remove_query_param(&mut self, position: usize) -> Option<QueryParam<'a>> {
+        let Some(mut i) = self
+            .query_params
+            .iter()
+            .position(|qp| qp.position == position)
+        else {
+            log::warn!(
+                "Attempted to remove non existent query param at position: {position} ({self})"
+            );
+            return None;
+        };
+
+        let removed = self.query_params.remove(i);
+
+        while let Some(qp) = self.query_params.get_mut(i) {
+            // +1 for the &
+            qp.position -= removed.total_len() + 1;
+            i += 1;
+        }
+
+        Some(removed)
     }
 }
 
@@ -395,29 +453,29 @@ impl<'a> Display for Segment<'a> {
     }
 }
 
+/// A parsed URL query parameter.
+#[cfg_attr(test, derive(Clone))]
 #[derive(Debug, PartialEq, Eq)]
 pub struct QueryParam<'a> {
     pub key: &'a str,
     pub value: &'a str,
-    pub pos: usize,
+
+    /// The byte position of query param. Since all URLs are ASCII,
+    /// this is also the char position.
+    pub position: usize,
 }
 
 impl<'a> QueryParam<'a> {
     fn parse(query: &'a str, mut offset: usize) -> (Vec<Self>, &'a str) {
+        dbg!(query);
+
         if query.is_empty() {
             return (vec![], "");
         }
 
-        if query == "?" {
-            return (vec![], "?");
-        }
-
+        // Skip the ?
         offset += 1;
 
-        let query = &query[1..];
-
-        let mut query_params = vec![];
-
         let (rest, params) = separated_list0(
             char('&'),
             separated_pair(
@@ -426,20 +484,32 @@ impl<'a> QueryParam<'a> {
                 take_while(|c: char| c != '&'),
             ),
         )
-        .parse(query)
+        .parse(&query[1..])
         .unwrap();
 
-        for param in params {
-            query_params.push(QueryParam {
-                key: param.0,
-                value: param.1,
-                pos: offset,
-            });
-            // +1 for the &, +1 for the =
-            offset += param.0.len() + 2 + param.1.len();
+        if params.is_empty() {
+            return (vec![], query);
         }
 
-        (query_params, rest)
+        let params = params
+            .iter()
+            .map(|param| {
+                let qp = QueryParam {
+                    key: param.0,
+                    value: param.1,
+                    position: offset,
+                };
+
+                // +1 for the &, +1 for the =
+                offset += param.0.len() + 2 + param.1.len();
+
+                qp
+            })
+            .collect();
+
+        dbg!(&params, rest);
+
+        (params, rest)
     }
 
     fn total_len(&self) -> usize {
@@ -448,7 +518,11 @@ impl<'a> QueryParam<'a> {
 
     #[cfg(test)]
     fn new(key: &'a str, value: &'a str, pos: usize) -> Self {
-        Self { key, value, pos }
+        Self {
+            key,
+            value,
+            position: pos,
+        }
     }
 }
 
@@ -457,7 +531,7 @@ impl<'a> From<QueryParam<'a>> for QueryParamOwned {
         Self {
             key: value.key.to_owned(),
             value: value.value.to_owned(),
-            pos: value.pos,
+            position: value.position,
         }
     }
 }
@@ -837,6 +911,66 @@ mod tests {
         );
     }
 
+    #[test]
+    fn remove_query_params_first_adjusts_position() {
+        let input = "http://localhost:4000?foo=69&bar=420&qux=1312";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+
+        let first = QueryParam::new("foo", "69", "http://localhost:4000?".len());
+        let second = QueryParam::new("bar", "420", "http://localhost:4000?foo=69&".len());
+        let third = QueryParam::new("qux", "1312", "http://localhost:4000?foo=69&bar=420&".len());
+
+        assert_eq!(
+            vec![first.clone(), second.clone(), third.clone()],
+            url.query_params
+        );
+
+        let qp = url.remove_query_param("http://localhost:4000?".len());
+
+        assert_eq!(first, qp.unwrap());
+
+        assert_eq!("http://localhost:4000?".len(), url.query_params[0].position);
+        assert_eq!(
+            "http://localhost:4000?bar=420&".len(),
+            url.query_params[1].position
+        );
+
+        assert_eq!("http://localhost:4000?bar=420&qux=1312", url.to_string());
+    }
+
+    #[test]
+    fn remove_query_params_middle_adjusts_position() {
+        let input = "http://localhost:4000?foo=69&bar=420&qux=1312";
+
+        let mut url = RequestUrl::parse(input).unwrap();
+
+        assert_eq!("http://localhost:4000", url.pre);
+
+        let first = QueryParam::new("foo", "69", "http://localhost:4000?".len());
+        let second = QueryParam::new("bar", "420", "http://localhost:4000?foo=69&".len());
+        let third = QueryParam::new("qux", "1312", "http://localhost:4000?foo=69&bar=420&".len());
+
+        assert_eq!(
+            vec![first.clone(), second.clone(), third.clone()],
+            url.query_params
+        );
+
+        let qp = url.remove_query_param("http://localhost:4000?foo=69&".len());
+
+        assert_eq!(second, qp.unwrap());
+
+        assert_eq!("http://localhost:4000?".len(), url.query_params[0].position);
+        assert_eq!(
+            "http://localhost:4000?foo=69&".len(),
+            url.query_params[1].position
+        );
+
+        assert_eq!("http://localhost:4000?foo=69&qux=1312", url.to_string());
+    }
+
     #[test]
     fn add_query_params_trailing_empty() {
         let input = "http://localhost:4000?";
@@ -1003,6 +1137,88 @@ mod tests {
         );
     }
 
+    #[test]
+    fn swap_path_adjusts_positions_query() {
+        let input = "http://foo.com/:ID/?foo=bar";
+
+        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
+        );
+
+        assert_eq!(
+            vec![QueryParam::new("foo", "bar", "http://foo.com/:ID/?".len()),],
+            url.query_params
+        );
+
+        url.swap_path_segment(Segment::Dynamic("42069", "http://foo.com".len()));
+
+        assert_eq!(
+            vec![
+                Segment::Dynamic("42069", "http://foo.com".len()),
+                Segment::Static("", "http://foo.com/:42069".len()),
+            ],
+            url.path
+        );
+
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://foo.com/:42069/?".len()
+            ),],
+            url.query_params
+        );
+
+        assert_eq!("http://foo.com/:42069/?foo=bar", url.to_string());
+    }
+
+    #[test]
+    fn populate_path_adjusts_positions_query() {
+        let input = "http://foo.com/:ID/?foo=bar";
+
+        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
+        );
+
+        assert_eq!(
+            vec![QueryParam::new("foo", "bar", "http://foo.com/:ID/?".len()),],
+            url.query_params
+        );
+
+        url.populate_path(HashMap::from([("ID", "42069")]));
+
+        assert_eq!(
+            vec![
+                Segment::Static("42069", "http://foo.com".len()),
+                Segment::Static("", "http://foo.com/42069".len()),
+            ],
+            url.path
+        );
+
+        assert_eq!(
+            vec![QueryParam::new(
+                "foo",
+                "bar",
+                "http://foo.com/42069/?".len()
+            ),],
+            url.query_params
+        );
+
+        assert_eq!("http://foo.com/42069/?foo=bar", url.to_string());
+    }
+
     #[test]
     fn swaps_dynamic_segment_and_offsets() {
         let input = "http://foo.com/bar/:ID/";

+ 1 - 22
src-tauri/src/workspace.rs

@@ -1,7 +1,4 @@
-use crate::{
-    db::Update,
-    request::{RequestPathUpdate, RequestQueryUpdate, WorkspaceRequest},
-};
+use crate::request::WorkspaceRequest;
 use serde::{Deserialize, Serialize};
 use sqlx::prelude::Type;
 
@@ -89,24 +86,6 @@ pub enum WorkspaceEntryCreate {
     },
 }
 
-#[derive(Debug, Deserialize)]
-pub enum WorkspaceEntryUpdate {
-    Collection(WorkspaceEntryUpdateBase),
-    Request {
-        base: WorkspaceEntryUpdateBase,
-        method: Option<String>,
-        url: Option<String>,
-        path_params: Option<Vec<RequestPathUpdate>>,
-        query_params: Option<Vec<RequestQueryUpdate>>,
-    },
-}
-
-#[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 {

+ 68 - 78
src/lib/components/WorkspaceEntry.svelte

@@ -12,7 +12,9 @@
     updateBodyContent,
     updateEntryName,
     updateHeader,
+    updateQueryParamEnabled,
     updateUrl,
+    type UrlUpdate,
   } from "$lib/state.svelte";
   import { Button } from "$lib/components/ui/button";
   import { Input } from "$lib/components/ui/input";
@@ -24,14 +26,13 @@
   import * as Resizable from "$lib/components/ui/resizable/index";
   import AuthParams from "./AuthParams.svelte";
   import Response from "./Response.svelte";
+  import Checkbox from "./ui/checkbox/checkbox.svelte";
 
   let requestPane: Resizable.Pane;
   let responsePane: Resizable.Pane;
 
   let isSending = $derived(_state.pendingRequests.includes(_state.entry!!.id));
 
-  let updateUrlTimeout: number | undefined = $state();
-
   const parentAuth = $derived.by(() => {
     let parentId = _state.entry!!.parent_id;
 
@@ -78,68 +79,34 @@
     }
   }
 
-  async function handleUrlUpdate(direct: boolean = false) {
-    const u = direct ? _state.entry!!.url : reconstructUrl();
+  let updateUrlTimeout: number | undefined = $state();
 
-    try {
-      await updateUrl(u, !direct);
-    } 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;
+  async function handleUrlUpdate(update: UrlUpdate) {
+    if (updateUrlTimeout != undefined) {
+      clearTimeout(updateUrlTimeout);
     }
-  }
-
-  /** Construct a URL from the binded input values for query and path parameters. */
-  function reconstructUrl(): string {
-    let url = _state.entry.workingUrl.pre;
-
-    for (const param of _state.entry.workingUrl.path) {
-      const [name, position] = param.value;
-
-      if (param.type === "Static") {
-        url += "/" + name;
-        continue;
-      }
-
-      const replacement = _state.entry!!.path.find(
-        (p) => p.position === position,
-      );
-
-      if (replacement !== undefined) {
-        url += "/:" + replacement.name;
-      } else {
-        url += "/:" + name;
+    updateUrlTimeout = setTimeout(async () => {
+      try {
+        await updateUrl(update);
+      } 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;
       }
-    }
-
-    if (_state.entry.workingUrl.query_params.length > 0) {
-      url +=
-        "?" +
-        _state.entry
-          .workingUrl!!.query_params.map((p) => `${p.key}=${p.value}`)
-          .join("&");
-    } else if (
-      _state.entry.workingUrl!!.trail &&
-      _state.entry.workingUrl.trail.length
-    ) {
-      url += _state.entry.workingUrl.trail;
-    }
-
-    return url;
+    }, 200);
   }
 </script>
 
@@ -266,12 +233,10 @@
         bind:value={_state.entry.url}
         placeholder="https://api.example.com/resource"
         oninput={() => {
-          if (updateUrlTimeout !== undefined) {
-            clearTimeout(updateUrlTimeout);
-          }
-          updateUrlTimeout = setTimeout(() => {
-            handleUrlUpdate(true);
-          }, 200);
+          handleUrlUpdate({
+            type: "URL",
+            url: _state.entry.url,
+          });
         }}
       />
 
@@ -301,7 +266,7 @@
             <!-- ================= PARAMETERS ================= -->
 
             <Tabs.Content value="params" class="space-y-4">
-              {#if _state.entry.path.length > 0}
+              {#if _state.entry?.path?.length > 0}
                 <div>
                   <h3 class="mb-2 text-sm font-medium">Path</h3>
                   <div class="grid grid-cols-2 gap-2 text-sm">
@@ -309,32 +274,57 @@
                       <Input
                         bind:value={param.name}
                         placeholder="key"
-                        oninput={() => handleUrlUpdate()}
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Path",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
                       <Input
                         bind:value={param.value}
                         placeholder="value"
-                        oninput={() => handleUrlUpdate()}
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Path",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
                     {/each}
                   </div>
                 </div>
               {/if}
 
-              {#if _state.entry.workingUrl?.query_params.length > 0}
+              {#if _state.entry?.query?.length > 0}
                 <div>
                   <h3 class="mb-2 text-sm font-medium">Query</h3>
-                  <div class="grid grid-cols-2 gap-2 text-sm">
-                    {#each _state.entry.workingUrl.query_params as param}
+                  <div class="grid grid-cols-3 gap-2 text-sm">
+                    {#each _state.entry.query as param}
+                      <Checkbox
+                        checked={param.position != null}
+                        onCheckedChange={() =>
+                          updateQueryParamEnabled(param.id)}
+                      />
                       <Input
                         bind:value={param.key}
                         placeholder="key"
-                        oninput={() => handleUrlUpdate()}
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Query",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
                       <Input
                         bind:value={param.value}
                         placeholder="value"
-                        oninput={() => handleUrlUpdate()}
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Query",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
                     {/each}
                   </div>
@@ -393,12 +383,12 @@
 
                 <Tabs.Content value="json">
                   <BodyEditor
-                    input={_state.entry.body?.body}
-                    type={_state.entry.body?.content_type}
+                    input={_state.entry.body?.content}
+                    type={_state.entry.body?.ty}
                     onStateChange={(update) => {
                       if (
                         update.docChanged &&
-                        _state.entry!!.body?.body !==
+                        _state.entry!!.body?.content !==
                           update.state.doc.toString()
                       ) {
                         updateBodyContent(update.state.doc.toString(), "Json");

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

@@ -13,6 +13,7 @@ import type {
   AuthType,
   HttpResponse,
   ResponseResult,
+  QueryParam,
 } from "./types";
 import { getSetting, setSetting } from "./settings.svelte";
 
@@ -185,6 +186,7 @@ export async function selectEntry(id: number) {
         headers: entry.data.headers,
         body: entry.data.body,
         path: entry.data.path_params,
+        query: entry.data.query_params,
       };
       break;
     }
@@ -204,13 +206,6 @@ export async function selectEntry(id: number) {
   }
 
   if (state.entry.type === "Request") {
-    parseUrl(state.entry!!.url)
-      .then(() =>
-        console.debug("working URL:", $state.snapshot(state.entry.workingUrl)),
-      )
-      .catch((e) => {
-        console.error("error parsing URL", e);
-      });
     expandUrl()
       .then(() =>
         console.debug(
@@ -413,58 +408,127 @@ export async function updateEntryName(name: string) {
 
   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,
+    name,
   });
 
   state.indexes[state.entry.id].name = name;
 }
 
-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,
-  });
-}
+/**
+ * Whether the URL string was edited directly in the URL bar,
+ * in a path parameter, or a query parameter.
+ */
+export type UrlUpdate =
+  | { type: "URL"; url: string }
+  | { type: "Path"; url: string; param: PathParam }
+  | { type: "Query"; url: string; param: QueryParam };
 
 /**
  * Update a request's URL string. If `usePathparams` is true, path entries
  * from state.entry.path will be used to replace those at the same position and should
  * be set to true whenever this is called from an input field of a destructured URL.
  */
-export async function updateUrl(u: string, usePathParams: boolean) {
-  console.log(u, usePathParams);
-  const [url, params] = await invoke<any[]>("update_url", {
+export async function updateUrl(up: UrlUpdate) {
+  let update;
+
+  switch (up.type) {
+    case "URL": {
+      update = { URL: up.url };
+      break;
+    }
+    case "Path": {
+      update = { Path: [up.url, up.param] };
+      break;
+    }
+    case "Query": {
+      update = { Query: [up.url, up.param] };
+      break;
+    }
+  }
+
+  const params = await invoke<{
+    url: RequestUrl;
+    full: string;
+    path: PathParam[];
+    query: QueryParam[];
+  }>("update_url", {
     entryId: state.entry!!.id,
-    usePathParams,
-    url: u,
-    pathParams: state.entry.path,
+    update,
   });
 
-  console.log(url);
+  switch (up.type) {
+    case "URL": {
+      // state.entry.url is already updated
+      // Direct URL updates must always fully update the parameters
+      state.entry!!.path = params.path;
+      state.entry!!.query = params.query;
+      break;
+    }
+    // Path updates are guaranteed not to modify the updated path param position
+    // They also guarantee the same amount of path parameters as before the update
+    case "Path": {
+      state.entry!!.url = params.full;
+      state.entry!!.query = params.query;
+
+      let i = 0;
+      for (const newParam of params.path) {
+        if (newParam.position <= up.param.position) {
+          i += 1;
+          continue;
+        }
+
+        state.entry!!.path[i] = newParam;
+        i += 1;
+      }
+
+      break;
+    }
+    // Query updates have the same guarantees as path updates
+    case "Query": {
+      state.entry!!.url = params.full;
+
+      let i = 0;
+      for (const newParam of params.query) {
+        if (newParam.position <= up.param.position) {
+          i += 1;
+          continue;
+        }
 
-  state.entry!!.url = u;
-  state.entry.path = params;
-  state.entry.workingUrl = url;
+        state.entry!!.query[i] = newParam;
+        i += 1;
+      }
+
+      break;
+    }
+  }
 
   expandUrl();
 
   console.debug("updated", $state.snapshot(state.entry));
 }
 
+export async function updateQueryParamEnabled(qpId: number) {
+  const qpIdx = state.entry.query.findIndex((qp) => qp.id === qpId);
+
+  if (qpIdx === -1) {
+    console.warn("query param does not exist!", qpId);
+    return;
+  }
+
+  const [newQp, url] = await invoke<string>("update_query_param_enabled", {
+    reqId: state.entry.id,
+    qpId,
+    url: state.entry!!.url,
+  });
+
+  console.log("updated QP", newQp, url);
+
+  state.entry!!.url = url;
+  state.entry.query[qpIdx] = newQp;
+}
+
 export async function expandUrl() {
   state.entry!!.expandedUrl = await invoke<string>("expand_url", {
     entryId: state.entry!!.id,
@@ -558,26 +622,18 @@ export async function deleteBody() {
   console.debug("Deleted request body");
 }
 
-export async function updateBodyContent(body: string, ct: string) {
+export async function updateBodyContent(content: string, ty: string) {
   if (state.entry!!.body != null) {
     await invoke("update_request_body", {
       id: state.entry!!.body.id,
-      body: {
-        Value: {
-          ty: ct,
-          content: body,
-        },
-      },
+      body: { Value: { ty, content } },
     });
-    state.entry!!.body.body = body;
-    state.entry!!.body.content_type = ct;
+    state.entry!!.body.content = content;
+    state.entry!!.body.ty = ty;
   } else {
     const b = await invoke("insert_request_body", {
       entryId: state.entry!!.id,
-      body: {
-        ty: ct,
-        content: body,
-      },
+      body: { ty, content },
     });
     state.entry!!.body = b;
   }
@@ -666,4 +722,5 @@ type WorkspaceRequestResponse = {
   body: RequestBody | null;
   headers: RequestHeader[];
   path_params: PathParam[];
+  query_params: QueryParam[];
 };

+ 6 - 4
src/lib/types.ts

@@ -15,6 +15,8 @@ export type WorkspaceEntryBase = {
   auth: number | null;
   auth_inherit: boolean;
 
+  type: string;
+
   // UI values,
   open?: boolean;
 };
@@ -35,7 +37,6 @@ export type WorkspaceRequest = WorkspaceEntryBase & {
 
   // Display fields
 
-  workingUrl?: RequestUrl;
   expandedUrl?: RequestUrl;
 };
 
@@ -67,7 +68,8 @@ export type PathParam = {
 };
 
 export type QueryParam = {
-  pos: number;
+  id: number;
+  position: number | null;
   key: string;
   value: string;
 };
@@ -93,8 +95,8 @@ export type WorkspaceCreateRequest = {
 
 export type RequestBody = {
   id: number;
-  body: string;
-  content_type: string;
+  content: string;
+  ty: string;
 };
 
 export type WorkspaceEnvironment = {