biblius 2 hete
szülő
commit
e8f148c53d

+ 24 - 4
README.md

@@ -1,7 +1,27 @@
-# Tauri + SvelteKit + TypeScript
+# Dovati
 
-This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
+## Development
 
-## Recommended IDE Setup
+Install `sqlite3` and initialise the DB.
 
-[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
+```bash
+sqlite3 rquest.db ".quit"
+```
+
+Set the env variable for `sqlx`.
+
+```bash
+export DATABASE_URL=sqlite:/absolute/path/to/rquest.db
+```
+
+Init some dummy requests.
+
+```bash
+sqlite3 rquest.db < src-tauri/seed/init.sql
+```
+
+Start with
+
+```bash
+npm run tauri dev
+```

+ 1 - 0
src-tauri/Cargo.lock

@@ -3656,6 +3656,7 @@ dependencies = [
 name = "rquest"
 version = "0.1.0"
 dependencies = [
+ "base64 0.22.1",
  "mime",
  "nom",
  "reqwest",

+ 1 - 1
src-tauri/Cargo.toml

@@ -39,7 +39,7 @@ reqwest = { version = "0.12.15", features = [
 ] }
 tauri-plugin-log = "2"
 tauri-plugin-store = "2"
+base64 = "0.22.1"
 
 [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
 tauri-plugin-global-shortcut = "2"
-

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

@@ -5,4 +5,5 @@ DROP TABLE request_path_params;
 DROP TABLE workspace_entries;
 DROP TABLE workspace_env_variables;
 DROP TABLE workspace_envs;
+DROP TABLE auth;
 DROP TABLE workspaces;

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

@@ -3,6 +3,13 @@ CREATE TABLE workspaces (
     name TEXT NOT NULL UNIQUE
 );
 
+CREATE TABLE auth(
+    id INTEGER PRIMARY KEY NOT NULL,
+    workspace_id INTEGER NOT NULL,
+    params JSONB NOT NULL,
+    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+);
+
 CREATE TABLE workspace_envs (
     id INTEGER PRIMARY KEY NOT NULL,
     workspace_id INTEGER NOT NULL,
@@ -28,8 +35,11 @@ CREATE TABLE workspace_entries (
     parent_id INTEGER,
     name TEXT NOT NULL,
     type INTEGER NOT NULL,
+    auth INTEGER,
+    auth_inherit BOOLEAN NOT NULL DEFAULT TRUE,
     FOREIGN KEY (parent_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
+    FOREIGN KEY (auth) REFERENCES auth (id) ON DELETE SET NULL
 );
 
 CREATE TABLE request_params (

+ 33 - 0
src-tauri/seed/init.sql

@@ -0,0 +1,33 @@
+-- Creates initial entries for a workspace and adds some collections and requests to it.
+-- Creates an environment.
+
+INSERT INTO workspaces(id, name) VALUES (0, 'My workspace');
+
+INSERT INTO workspace_envs(id, workspace_id, name) VALUES (0, 0, 'My env');
+
+INSERT INTO 
+  workspace_env_variables(id, workspace_id, env_id, name, value, secret)
+  VALUES(0, 0, 0, 'BASE_URL', 'https://jsonplaceholder.typicode.com', false);
+
+INSERT INTO 
+  workspace_entries(id, workspace_id, parent_id, name, type)
+  VALUES (0, 0, NULL, 'My collection', 1);
+
+INSERT INTO 
+  workspace_entries(id, workspace_id, parent_id, name, type)
+  VALUES 
+    (1, 0, 0, 'My request in col', 0),
+    (2, 0, NULL, 'My request', 0);
+
+INSERT INTO 
+  request_params(workspace_id, request_id, method, url)
+  VALUES 
+    (0, 1, 'GET', '{{BASE_URL}}/posts/:ID'),
+    (0, 2, 'GET', '{{BASE_URL}}/todos/:ID');
+
+INSERT INTO request_path_params(position, request_id, name, value)
+  VALUES
+    (18, 1, 'ID', '1'),
+    (18, 2, 'ID', '1');
+
+INSERT INTO request_headers(request_id, name, value) VALUES(1, 'accept', '*/*');

+ 143 - 0
src-tauri/src/auth.rs

@@ -0,0 +1,143 @@
+use serde::{Deserialize, Serialize};
+use sqlx::SqlitePool;
+
+use crate::{
+    db,
+    var::{expand_vars, parse_vars},
+    AppResult,
+};
+
+#[derive(Debug, Deserialize)]
+pub enum AuthType {
+    Token,
+    Basic,
+    OAuth2,
+}
+
+impl From<AuthType> for Auth {
+    fn from(value: AuthType) -> Self {
+        match value {
+            AuthType::Token => Self::Token(TokenAuth::default()),
+            AuthType::Basic => Self::Basic(BasicAuth::default()),
+            AuthType::OAuth2 => Self::OAuth2(OAuth::default()),
+        }
+    }
+}
+
+#[derive(Debug, Serialize)]
+pub struct Authentication {
+    pub id: i64,
+    pub workspace_id: i64,
+    pub params: Auth,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub enum Auth {
+    Token(TokenAuth),
+    Basic(BasicAuth),
+    OAuth2(OAuth),
+}
+
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub struct TokenAuth {
+    pub placement: TokenPlacement,
+    pub key: String,
+
+    /// The value will get expanded, therefore any variables present will expand to whatever is
+    /// in the env.
+    pub value: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub enum TokenPlacement {
+    /// Holds the value for the key
+    Query(String),
+    /// Holds the name for the header
+    Header(String),
+}
+
+impl Default for TokenPlacement {
+    fn default() -> Self {
+        Self::Header(String::default())
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub struct BasicAuth {
+    pub user: String,
+    pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub struct OAuth {
+    pub token_name: String,
+
+    pub callback_url: String,
+    pub auth_url: String,
+    pub token_url: String,
+    pub refresh_url: Option<String>,
+
+    pub client_id: String,
+    pub client_secret: String,
+    pub scope: Option<Vec<String>>,
+    pub state: Option<String>,
+
+    pub grant_type: GrantType,
+}
+
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub enum GrantType {
+    #[default]
+    AuthorizationCode,
+    AuthorizationCodePKCE {
+        verifier: Option<String>,
+    },
+    ClientCredentials,
+}
+
+pub async fn expand_auth_vars(auth: &mut Auth, pool: &SqlitePool, env_id: i64) -> AppResult<()> {
+    match auth {
+        crate::auth::Auth::Token(ref mut token) => {
+            let vars = parse_vars(&token.value)
+                .iter()
+                .map(|v| v.name)
+                .collect::<Vec<_>>();
+
+            let vars = db::get_env_variables(pool, env_id, &vars).await?;
+
+            token.value = expand_vars(&token.value, &vars);
+        }
+        crate::auth::Auth::Basic(BasicAuth { user, password }) => {
+            let vars = parse_vars(&user).iter().map(|v| v.name).collect::<Vec<_>>();
+
+            let vars = db::get_env_variables(pool, env_id, &vars).await?;
+
+            *user = expand_vars(&user, &vars);
+
+            let vars = parse_vars(&password)
+                .iter()
+                .map(|v| v.name)
+                .collect::<Vec<_>>();
+
+            let vars = db::get_env_variables(&pool, env_id, &vars).await?;
+
+            *password = expand_vars(&password, &vars);
+        }
+        crate::auth::Auth::OAuth2(OAuth {
+            token_name,
+            callback_url,
+            auth_url,
+            token_url,
+            refresh_url,
+            client_id,
+            client_secret,
+            scope,
+            state,
+            grant_type,
+        }) => {
+            todo!()
+        }
+    }
+
+    Ok(())
+}

+ 127 - 52
src-tauri/src/cmd.rs

@@ -1,4 +1,5 @@
 use crate::{
+    auth::{expand_auth_vars, Auth, AuthType, Authentication, BasicAuth, OAuth},
     db::{self, Update},
     request::{
         self,
@@ -111,13 +112,10 @@ pub async fn expand_url(
     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())),
-        };
+        let vars = parse_vars(&url).iter().map(|v| v.name).collect::<Vec<_>>();
 
         if !vars.is_empty() {
-            let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+            let vars = match db::get_env_variables(&state.db, env_id, &vars).await {
                 Ok(v) => v,
                 Err(e) => return Err(UrlError::Db(e.to_string())),
             };
@@ -142,10 +140,7 @@ pub async fn expand_url(
 
             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())),
-            };
+            let vars = parse_vars(&url).iter().map(|v| v.name).collect::<Vec<_>>();
 
             if vars.is_empty() {
                 return Ok(url);
@@ -155,7 +150,7 @@ pub async fn expand_url(
                 return Ok(url);
             };
 
-            let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+            let vars = match db::get_env_variables(&state.db, env_id, &vars).await {
                 Ok(v) => v,
                 Err(e) => return Err(UrlError::Db(e.to_string())),
             };
@@ -190,33 +185,35 @@ pub async fn update_url(
             let mut subs = vec![];
 
             for seg in url_parsed.path.iter_mut() {
-                if let Segment::Dynamic(seg, position) = seg {
-                    let Some(path_param) = path_params
-                        .iter()
-                        .find(|pp| pp.position as usize == *position || &pp.name == seg)
-                    else {
-                        update.push(RequestPathUpdate {
-                            position: *position,
-                            name: seg.to_string(),
-                            value: None,
-                        });
-                        continue;
-                    };
-
-                    if use_path_params {
-                        update.push(RequestPathUpdate {
-                            position: *position,
-                            name: path_param.name.clone(),
-                            value: Some(path_param.value.clone()),
-                        });
-                        subs.push(Segment::Dynamic(&path_param.name, *position));
-                    } else {
-                        update.push(RequestPathUpdate {
-                            position: *position,
-                            name: seg.to_string(),
-                            value: Some(path_param.value.clone()),
-                        })
-                    }
+                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 {
+                    update.push(RequestPathUpdate {
+                        position: *position,
+                        name: seg.to_string(),
+                        value: None,
+                    });
+                    continue;
+                };
+
+                if use_path_params {
+                    update.push(RequestPathUpdate {
+                        position: *position,
+                        name: path_param.name.clone(),
+                        value: Some(path_param.value.clone()),
+                    });
+                    subs.push(Segment::Dynamic(&path_param.name, *position));
+                } else {
+                    update.push(RequestPathUpdate {
+                        position: *position,
+                        name: seg.to_string(),
+                        value: Some(path_param.value.clone()),
+                    })
                 }
             }
 
@@ -267,12 +264,12 @@ pub async fn send_request(
     };
 
     req.url = if let Some(env_id) = env_id {
-        let vars = match parse_vars(&req.url) {
-            Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
-            Err(e) => return Err(e.to_string()),
-        };
+        let vars = parse_vars(&req.url)
+            .iter()
+            .map(|v| v.name)
+            .collect::<Vec<_>>();
 
-        let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+        let vars = match db::get_env_variables(&state.db, env_id, &vars).await {
             Ok(v) => v,
             Err(e) => return Err(e.to_string()),
         };
@@ -289,6 +286,8 @@ pub async fn send_request(
                 Err(e) => return Err(e.to_string()),
             };
 
+            // Populate path placeholders
+
             url.populate_path(
                 params
                     .iter()
@@ -296,23 +295,56 @@ pub async fn send_request(
                     .collect(),
             );
 
+            // Expand any remaining parameters that are variables
+
             req.url = url.to_string();
 
-            let vars = match parse_vars(&req.url) {
-                Ok(vars) => vars.iter().map(|v| v.name).collect::<Vec<_>>(),
-                Err(e) => return Err(e.to_string()),
-            };
+            if let Some(env_id) = env_id {
+                let vars = parse_vars(&req.url)
+                    .iter()
+                    .map(|v| v.name)
+                    .collect::<Vec<_>>();
 
-            req.url = if let Some(env_id) = env_id {
-                let vars = match db::get_env_variables(state.db.clone(), env_id, &vars).await {
+                let vars = match db::get_env_variables(&state.db, env_id, &vars).await {
                     Ok(v) => v,
                     Err(e) => return Err(e.to_string()),
                 };
 
-                expand_vars(&url.to_string(), &vars)
-            } else {
-                req.url
-            };
+                req.url = expand_vars(&req.url, &vars)
+            }
+
+            // Check auth and append to the appropriate params
+
+            if req.entry.auth_inherit {
+                if let Some(auth) = db::get_auth_inherited(state.db.clone(), req.entry.parent_id)
+                    .await
+                    .map_err(|e| e.to_string())?
+                {
+                    let mut auth = db::get_auth(state.db.clone(), auth)
+                        .await
+                        .map_err(|e| e.to_string())?;
+
+                    if let Some(env_id) = env_id {
+                        expand_auth_vars(&mut auth.params, &state.db, env_id)
+                            .await
+                            .map_err(|e| e.to_string())?;
+                    }
+
+                    req.resolve_auth(auth.params).map_err(|e| e.to_string())?;
+                }
+            } else if let Some(auth) = req.entry.auth {
+                let mut auth = db::get_auth(state.db.clone(), auth)
+                    .await
+                    .map_err(|e| e.to_string())?;
+
+                if let Some(env_id) = env_id {
+                    expand_auth_vars(&mut auth.params, &state.db, env_id)
+                        .await
+                        .map_err(|e| e.to_string())?;
+                }
+
+                req.resolve_auth(auth.params).map_err(|e| e.to_string())?;
+            }
 
             HttpRequestParameters::try_from(req)?
         }
@@ -435,3 +467,46 @@ pub async fn delete_header(
     }
     Ok(())
 }
+
+#[tauri::command]
+pub async fn set_workspace_entry_auth(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+    auth_id: Option<i64>,
+) -> Result<(), String> {
+    if let Err(e) = db::set_workspace_entry_auth(state.db.clone(), entry_id, auth_id).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn insert_auth(
+    state: tauri::State<'_, AppState>,
+    workspace_id: i64,
+    r#type: AuthType,
+) -> Result<Authentication, String> {
+    match db::insert_auth(state.db.clone(), workspace_id, r#type.into()).await {
+        Ok(auth) => Ok(auth),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn list_auth(
+    state: tauri::State<'_, AppState>,
+    workspace_id: i64,
+) -> Result<Vec<Authentication>, String> {
+    match db::list_auth(state.db.clone(), workspace_id).await {
+        Ok(auth) => Ok(auth),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn delete_auth(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> {
+    match db::delete_auth(state.db.clone(), id).await {
+        Ok(_) => Ok(()),
+        Err(e) => Err(e.to_string()),
+    }
+}

+ 128 - 10
src-tauri/src/db.rs

@@ -1,4 +1,5 @@
 use crate::{
+    auth::{Auth, Authentication},
     error::AppError,
     request::{
         EntryRequestBody, RequestBody, RequestHeader, RequestHeaderInsert, RequestHeaderUpdate,
@@ -11,12 +12,11 @@ use crate::{
     AppResult,
 };
 use serde::Deserialize;
-use sqlx::{sqlite::SqlitePool, QueryBuilder};
+use sqlx::{sqlite::SqlitePool, types::Json, QueryBuilder};
 use std::collections::HashMap;
 use tauri_plugin_log::log;
 
-/// 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.
+/// 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)]
 pub enum Update<T> {
     Value(T),
@@ -112,7 +112,7 @@ pub async fn create_workspace_entry(
             let entry = sqlx::query_as!(
                 WorkspaceEntryBase,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
-                   RETURNING id, workspace_id, parent_id, name, type"#,
+                   RETURNING id, workspace_id, parent_id, name, type, auth, auth_inherit"#,
                 name,
                 workspace_id,
                 parent_id,
@@ -146,7 +146,7 @@ pub async fn create_workspace_entry(
             let entry = match sqlx::query_as!(
                 WorkspaceEntryBase,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
-                   RETURNING id, workspace_id, name, parent_id, type"#,
+                   RETURNING id, workspace_id, name, parent_id, type, auth, auth_inherit"#,
                 name,
                 workspace_id,
                 parent_id,
@@ -344,7 +344,14 @@ pub async fn update_workspace_entry(
             };
 
             if let Some(path_params) = path_params {
-                if !path_params.is_empty() {
+                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) ",
                     );
@@ -396,7 +403,7 @@ pub async fn update_workspace_entry(
 pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<WorkspaceRequest> {
     let entry = sqlx::query_as!(
         WorkspaceEntryBase,
-        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE id = ?",
+        "SELECT id, workspace_id, parent_id, name, type, auth, auth_inherit FROM workspace_entries WHERE id = ?",
         id,
     )
     .fetch_one(&db)
@@ -451,7 +458,7 @@ pub async fn list_workspace_entries(
 ) -> AppResult<Vec<WorkspaceEntry>> {
     let entries = sqlx::query_as!(
         WorkspaceEntryBase,
-        "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
+        "SELECT id, workspace_id, parent_id, name, type, auth, auth_inherit FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
         workspace_id,
     )
     .fetch_all(&db)
@@ -574,7 +581,7 @@ pub async fn list_environments(
 }
 
 pub async fn get_env_variables(
-    db: SqlitePool,
+    db: &SqlitePool,
     env_id: i64,
     names: &[&str],
 ) -> AppResult<Vec<(String, String)>> {
@@ -593,7 +600,7 @@ pub async fn get_env_variables(
 
     Ok(query
         .build_query_as::<(String, String)>()
-        .fetch_all(&db)
+        .fetch_all(db)
         .await?)
 }
 
@@ -748,3 +755,114 @@ pub async fn delete_header(db: SqlitePool, header_id: i64) -> AppResult<()> {
         .await?;
     Ok(())
 }
+
+pub async fn insert_auth(
+    db: SqlitePool,
+    workspace_id: i64,
+    params: Auth,
+) -> AppResult<Authentication> {
+    let json = Json(&params);
+
+    let record = sqlx::query!(
+        "INSERT INTO auth(workspace_id, params) VALUES (?, ?) RETURNING id",
+        workspace_id,
+        json
+    )
+    .fetch_one(&db)
+    .await?;
+
+    Ok(Authentication {
+        id: record.id,
+        workspace_id,
+        params,
+    })
+}
+
+pub async fn delete_auth(db: SqlitePool, id: i64) -> AppResult<()> {
+    sqlx::query!("DELETE FROM auth WHERE id = ?", id)
+        .execute(&db)
+        .await?;
+    Ok(())
+}
+
+pub async fn list_auth(db: SqlitePool, workspace_id: i64) -> AppResult<Vec<Authentication>> {
+    let records = sqlx::query!(
+        r#"
+        SELECT id, workspace_id, params as "params: Json<Auth>"
+        FROM auth
+        WHERE workspace_id = ?
+        "#,
+        workspace_id
+    )
+    .fetch_all(&db)
+    .await?;
+
+    Ok(records
+        .into_iter()
+        .map(|record| Authentication {
+            id: record.id,
+            workspace_id: record.workspace_id,
+            params: record.params.0,
+        })
+        .collect())
+}
+
+pub async fn get_auth(db: SqlitePool, id: i64) -> AppResult<Authentication> {
+    let record = sqlx::query!(
+        r#"
+        SELECT id, workspace_id, params as "params: Json<Auth>"
+        FROM auth
+        WHERE id = ?
+        "#,
+        id
+    )
+    .fetch_one(&db)
+    .await?;
+
+    Ok(Authentication {
+        id: record.id,
+        workspace_id: record.workspace_id,
+        params: record.params.0,
+    })
+}
+
+pub async fn set_workspace_entry_auth(
+    db: SqlitePool,
+    entry_id: i64,
+    auth_id: Option<i64>,
+) -> AppResult<()> {
+    sqlx::query!(
+        "UPDATE workspace_entries SET auth = ? WHERE id = ?",
+        auth_id,
+        entry_id
+    )
+    .execute(&db)
+    .await?;
+
+    Ok(())
+}
+
+/// Check for the existence of an auth ID in the workspace entry. If one does not exist,
+/// traverse its parents and attempt to find the first one that is present. If none exist,
+/// returns `None`.
+pub async fn get_auth_inherited(
+    db: SqlitePool,
+    mut parent_id: Option<i64>,
+) -> AppResult<Option<i64>> {
+    while let Some(id) = parent_id {
+        let record = sqlx::query!(
+            "SELECT auth, auth_inherit, parent_id FROM workspace_entries WHERE id = ?",
+            id
+        )
+        .fetch_one(&db)
+        .await?;
+
+        if !record.auth_inherit {
+            return Ok(record.auth);
+        }
+
+        parent_id = record.parent_id;
+    }
+
+    Ok(None)
+}

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

@@ -14,4 +14,7 @@ pub enum AppError {
     // Domain specific errors
     #[error("{0}")]
     InvalidUpdate(String),
+
+    #[error("{0}")]
+    UrlParse(#[from] crate::request::url::UrlParseError),
 }

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

@@ -4,6 +4,7 @@ use tauri_plugin_store::StoreExt;
 
 pub type AppResult<T> = Result<T, error::AppError>;
 
+mod auth;
 mod cmd;
 mod collection;
 mod db;
@@ -51,6 +52,10 @@ pub fn run() {
             cmd::delete_header,
             cmd::insert_request_body,
             cmd::update_request_body,
+            cmd::insert_auth,
+            cmd::set_workspace_entry_auth,
+            cmd::list_auth,
+            cmd::delete_auth,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 55 - 1
src-tauri/src/request.rs

@@ -3,7 +3,13 @@ pub mod url;
 
 use std::str::FromStr;
 
-use crate::{db::Update, request::ctype::ContentType, workspace::WorkspaceEntryBase, AppResult};
+use crate::{
+    auth::{Auth, BasicAuth, OAuth},
+    request::{ctype::ContentType, url::RequestUrl},
+    workspace::WorkspaceEntryBase,
+    AppResult,
+};
+use base64::{prelude::BASE64_STANDARD, Engine};
 use reqwest::{
     header::{self, HeaderMap, HeaderValue},
     Body, Method,
@@ -131,6 +137,54 @@ impl WorkspaceRequest {
             path_params,
         }
     }
+
+    pub fn resolve_auth(&mut self, auth: Auth) -> AppResult<()> {
+        match auth {
+            crate::auth::Auth::Token(token) => match token.placement {
+                crate::auth::TokenPlacement::Query(name) => {
+                    let mut url = RequestUrl::parse(&self.url)?;
+
+                    url.query_params.push((&name, &token.value));
+
+                    self.url = url.to_string();
+                }
+                crate::auth::TokenPlacement::Header(name) => {
+                    self.headers.push(RequestHeader {
+                        id: -1,
+                        name,
+                        value: token.value,
+                    });
+                }
+            },
+            crate::auth::Auth::Basic(BasicAuth { user, password }) => {
+                let value = format!(
+                    "Basic {}",
+                    BASE64_STANDARD.encode(format!("{user}:{password}"))
+                );
+                self.headers.push(RequestHeader {
+                    id: -1,
+                    name: "Authorization".to_string(),
+                    value,
+                });
+            }
+            crate::auth::Auth::OAuth2(OAuth {
+                token_name,
+                callback_url,
+                auth_url,
+                token_url,
+                refresh_url,
+                client_id,
+                client_secret,
+                scope,
+                state,
+                grant_type,
+            }) => {
+                todo!()
+            }
+        }
+
+        Ok(())
+    }
 }
 
 /// Finalized request parameters obtained from a [WorkspaceRequest].

+ 7 - 1
src-tauri/src/request/url.rs

@@ -390,7 +390,7 @@ pub enum UrlError {
     Db(String),
 }
 
-#[derive(Debug, Serialize)]
+#[derive(thiserror::Error, Debug, Serialize)]
 pub struct UrlParseError {
     #[serde(serialize_with = "serialize_kind")]
     kind: Option<nom::error::ErrorKind>,
@@ -400,6 +400,12 @@ pub struct UrlParseError {
     recoverable: bool,
 }
 
+impl Display for UrlParseError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{self:?}")
+    }
+}
+
 fn map_nom_err<'a>(
     input: &'a str,
     token: Option<&'a str>,

+ 18 - 31
src-tauri/src/var.rs

@@ -1,5 +1,3 @@
-use std::collections::HashMap;
-
 use nom::{
     bytes::{
         complete::{tag, take_until},
@@ -8,22 +6,6 @@ use nom::{
     sequence::delimited,
     Parser,
 };
-use serde::Serialize;
-
-#[derive(Debug, Serialize)]
-pub struct VarOwned {
-    pub pos: usize,
-    pub name: String,
-}
-
-impl<'a> From<Var<'a>> for VarOwned {
-    fn from(var: Var<'a>) -> Self {
-        VarOwned {
-            pos: var.pos,
-            name: var.name.to_owned(),
-        }
-    }
-}
 
 const VAR_START: &str = "{{";
 const VAR_END: &str = "}}";
@@ -48,9 +30,7 @@ pub fn expand_vars<'a>(input: &'a str, vars: &[(String, String)]) -> String {
     out
 }
 
-pub fn parse_vars<'a>(
-    mut input: &'a str,
-) -> Result<Vec<Var<'a>>, nom::Err<nom::error::Error<&'a str>>> {
+pub fn parse_vars<'a>(mut input: &'a str) -> Vec<Var<'a>> {
     let mut var_parser = delimited(
         tag::<_, _, nom::error::Error<_>>(VAR_START),
         take_until("}"),
@@ -61,10 +41,9 @@ 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);
+            if input == rest {
+                return vars;
             }
 
             input = rest;
@@ -78,7 +57,7 @@ pub fn parse_vars<'a>(
         let Ok((var_start, consumed)) =
             take_until1::<_, _, nom::error::Error<_>>(VAR_START).parse(input)
         else {
-            return Ok(vars);
+            return vars;
         };
 
         offset += consumed.len();
@@ -94,14 +73,22 @@ mod test {
     #[test]
     fn parses_no_variables() {
         let url = "http://foo.bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
         assert!(result.is_empty());
     }
 
+    #[test]
+    fn parses_single_variable() {
+        let url = "{{BASE_URL}}";
+        let result = parse_vars(url);
+        assert_eq!(0, result[0].pos);
+        assert_eq!("BASE_URL", result[0].name);
+    }
+
     #[test]
     fn parses_variable_start() {
         let url = "{{BASE_URL}}/foo/bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
         assert_eq!(0, result[0].pos);
         assert_eq!("BASE_URL", result[0].name);
     }
@@ -109,7 +96,7 @@ mod test {
     #[test]
     fn parses_variables_start_adjacent() {
         let url = "{{BASE_URL}}{{FOO}}/foo/bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
         assert_eq!(0, result[0].pos);
         assert_eq!("BASE_URL", result[0].name);
         assert_eq!(12, result[1].pos);
@@ -119,7 +106,7 @@ mod test {
     #[test]
     fn parses_variables_start_apart() {
         let url = "{{BASE_URL}}/api/{{FOO}}/foo/bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
         assert_eq!(0, result[0].pos);
         assert_eq!("BASE_URL", result[0].name);
         assert_eq!("{{BASE_URL}}/api/".len(), result[1].pos);
@@ -129,7 +116,7 @@ mod test {
     #[test]
     fn parses_variables_apart() {
         let url = "https://{{HOST}}:{{PORT}}/api/{{FOO}}/foo/bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
 
         assert_eq!("https://".len(), result[0].pos);
         assert_eq!("HOST", result[0].name);
@@ -144,7 +131,7 @@ mod test {
     #[test]
     fn skips_single() {
         let url = "https://{HOST}:{{PORT}}/api/{{FOO}}/foo/bar";
-        let result = parse_vars(url).unwrap();
+        let result = parse_vars(url);
 
         assert_eq!("https://{HOST}:".len(), result[0].pos);
         assert_eq!("PORT", result[0].name);

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

@@ -28,14 +28,29 @@ impl WorkspaceEntry {
     }
 }
 
-/// Database model representation
+/// Database model representation of either a collection or a request.
 #[derive(Debug, Serialize)]
 pub struct WorkspaceEntryBase {
+    /// Entry ID.
     pub id: i64,
+
+    /// Containing workspace ID.
     pub workspace_id: i64,
+
+    /// If present, holds the parent collection ID of the entry.
     pub parent_id: Option<i64>,
+
+    /// User friendly display name for the entry.
     pub name: String,
+
+    /// Whether this type is a collection or a request.
     pub r#type: WorkspaceEntryType,
+
+    /// If present, holds the [Authentication][crate::auth::Authentication] ID of the entry.
+    pub auth: Option<i64>,
+
+    /// If `true`, the entry will inherit the auth scheme of its parent entry.
+    pub auth_inherit: bool,
 }
 
 #[derive(Debug, Serialize)]

+ 7 - 2
src/lib/components/WorkspaceEntry.svelte

@@ -52,6 +52,10 @@
     } catch (e) {
       console.error("error sending request", e);
     } finally {
+      if (responsePane.getSize() === 0) {
+        requestPane.resize(50);
+        responsePane.resize(50);
+      }
       console.timeEnd("request");
       isSending = false;
     }
@@ -234,7 +238,7 @@
                       <Input
                         bind:value={param.name}
                         placeholder="key"
-                        oninput={() => handleUrlUpdate()}
+                        oninput={(_) => handleUrlUpdate()}
                       />
                       <Input
                         bind:value={param.value}
@@ -370,13 +374,14 @@
 
       <!-- RESPONSE -->
 
-      <Resizable.Pane defaultSize={0} bind:this={responsePane}>
+      <Resizable.Pane class="p-2" defaultSize={0} bind:this={responsePane}>
         {#if isSending}
           <div class="flex justify-center py-8">
             <Loader class="h-6 w-6 animate-spin text-muted-foreground" />
           </div>
         {:else if response}
           <!-- Prevents line number selection -->
+          <p>{response.status}</p>
           <div
             class="
                   w-full

+ 3 - 0
src/lib/state.svelte.ts

@@ -327,6 +327,7 @@ export async function parseUrl(url: string) {
  * 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", {
     entryId: state.entry!!.id,
     usePathParams,
@@ -334,6 +335,8 @@ export async function updateUrl(u: string, usePathParams: boolean) {
     pathParams: state.entry.path,
   });
 
+  console.log(url);
+
   state.entry!!.url = u;
   state.entry.path = params;
   state.entry.workingUrl = url;

+ 42 - 9
src/routes/+page.svelte

@@ -5,9 +5,10 @@
   import { Button } from "$lib/components/ui/button";
   import { SunIcon, MoonIcon } from "@lucide/svelte";
   import WorkspaceEntry from "$lib/components/WorkspaceEntry.svelte";
-  import { state as _state } from "$lib/state.svelte";
+  import { state as _state, selectEnvironment } from "$lib/state.svelte";
   import { SlidersHorizontal } from "@lucide/svelte";
   import Environment from "$lib/components/Environment.svelte";
+  import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index";
 
   let displayEnvs = $state(false);
 </script>
@@ -15,15 +16,47 @@
 <Sidebar.Provider>
   <AppSidebar />
 
-  {#if displayEnvs}
-    <Environment />
-  {:else if _state.entry}
-    <main class="w-full p-4 space-y-4">
+  <main class="w-full p-4 space-y-4">
+    <header class="flex items-center w-full border-b pb-2">
+      <p class="w-full">{_state.workspace?.name ?? "-"}</p>
+      <p class="text-center mr-2">Environment:</p>
+      <DropdownMenu.Root>
+        <DropdownMenu.Trigger>
+          {#snippet child({ props })}
+            <div class="flex justify-center w-1/8">
+              <!-- Workspace name -->
+              <Sidebar.MenuButton
+                class="flex justify-center"
+                {...props}
+                variant="outline"
+              >
+                {_state.environment?.name || "-"}
+              </Sidebar.MenuButton>
+            </div>
+          {/snippet}
+        </DropdownMenu.Trigger>
+
+        <DropdownMenu.Content align="center">
+          <DropdownMenu.Item onSelect={() => selectEnvironment(null)}
+            >- {_state.environment === null ? " ✓" : ""}</DropdownMenu.Item
+          >
+
+          {#each _state.environments as env}
+            <DropdownMenu.Item onSelect={() => selectEnvironment(env.id)}
+              >{env.name}{_state.environment?.id === env.id
+                ? " ✓"
+                : ""}</DropdownMenu.Item
+            >
+          {/each}
+        </DropdownMenu.Content>
+      </DropdownMenu.Root>
+    </header>
+    {#if displayEnvs}
+      <Environment />
+    {:else if _state.entry}
       <WorkspaceEntry />
-    </main>
-  {:else}
-    <main class="w-full p-4 space-y-4"></main>
-  {/if}
+    {:else}{/if}
+  </main>
 
   <Sidebar.Provider style="--sidebar-width: 3.5rem">
     <Sidebar.Root fixed={false} variant="floating" side="right">