snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,414 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::util::generate_prefixed_id;
use include_dir::{Dir, include_dir};
use log::{debug, info};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use std::ops::Deref;
use yaak_database::{ConnectionOrTx, SqlitePool};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
/// A chunk of body data stored in the blob database.
#[derive(Debug, Clone)]
pub struct BodyChunk {
pub id: String,
pub body_id: String,
pub chunk_index: i32,
pub data: Vec<u8>,
}
impl BodyChunk {
pub fn new(body_id: impl Into<String>, chunk_index: i32, data: Vec<u8>) -> Self {
Self { id: generate_prefixed_id("bc"), body_id: body_id.into(), chunk_index, data }
}
}
/// Manages the blob database: a reader pool and a single writer, for the
/// same reason as [`crate::query_manager::QueryManager`].
// Pools are internally synchronized — don't wrap them in a Mutex. A Mutex held across the
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct BlobManager {
readers: SqlitePool,
writer: SqlitePool,
}
impl BlobManager {
/// `writer` must be a pool with a single connection.
pub fn new(readers: SqlitePool, writer: SqlitePool) -> Self {
Self { readers, writer }
}
/// A read handle from the reader pool.
pub fn connect(&self) -> BlobContext<'_> {
let conn = self.readers.get().expect("Failed to get blob DB connection from pool");
BlobContext { conn: ConnectionOrTx::Connection(conn) }
}
/// Run `func` in a transaction on the writer connection.
pub fn with_tx<T, E>(
&self,
func: impl FnOnce(&BlobWriter) -> std::result::Result<T, E>,
) -> std::result::Result<T, E>
where
E: From<crate::error::Error>,
{
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.map_err(crate::error::Error::SqlError)?;
let writer = BlobWriter { ctx: BlobContext { conn: ConnectionOrTx::Transaction(&tx) } };
match func(&writer) {
Ok(val) => {
tx.commit().map_err(|e| {
GenericError(format!("Failed to commit blob transaction {e:?}"))
})?;
Ok(val)
}
Err(e) => {
tx.rollback().map_err(|e| {
GenericError(format!("Failed to rollback blob transaction {e:?}"))
})?;
Err(e)
}
}
}
}
/// Read handle for the blob database.
pub struct BlobContext<'a> {
conn: ConnectionOrTx<'a>,
}
impl<'a> BlobContext<'a> {
/// Get all chunks for a body, ordered by chunk_index.
pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {
let mut stmt = self.conn.prepare(
"SELECT id, body_id, chunk_index, data FROM body_chunks
WHERE body_id = ?1 ORDER BY chunk_index ASC",
)?;
let chunks = stmt
.query_map(params![body_id], |row| {
Ok(BodyChunk {
id: row.get(0)?,
body_id: row.get(1)?,
chunk_index: row.get(2)?,
data: row.get(3)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(chunks)
}
/// List all distinct body IDs in the blob database.
pub fn list_body_ids(&self) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare("SELECT DISTINCT body_id FROM body_chunks")?;
let ids = stmt
.query_map([], |row| row.get(0))?
.collect::<std::result::Result<Vec<String>, _>>()?;
Ok(ids)
}
/// Get total size of a body without loading data.
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
let size: i64 = self
.conn
.resolve()
.query_row(
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1",
params![body_id],
|row| row.get(0),
)
.unwrap_or(0);
Ok(size as usize)
}
/// Check if a body exists.
pub fn body_exists(&self, body_id: &str) -> Result<bool> {
let count: i64 = self
.conn
.resolve()
.query_row(
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
params![body_id],
|row| row.get(0),
)
.unwrap_or(0);
Ok(count > 0)
}
}
/// Write handle for the blob database. Derefs to [`BlobContext`] for reads.
pub struct BlobWriter<'a> {
ctx: BlobContext<'a>,
}
impl<'a> Deref for BlobWriter<'a> {
type Target = BlobContext<'a>;
fn deref(&self) -> &BlobContext<'a> {
&self.ctx
}
}
impl<'a> BlobWriter<'a> {
/// Insert a single chunk.
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
self.conn.execute(
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
)?;
Ok(())
}
/// Delete all chunks for a body.
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
Ok(())
}
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all
/// bodies for a response).
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
self.conn
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
Ok(())
}
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
info!("Running blob database migrations");
// Create migrations tracking table
pool.get()?.execute(
"CREATE TABLE IF NOT EXISTS _blob_migrations (
version TEXT PRIMARY KEY,
description TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)",
[],
)?;
// Read and sort all .sql files
let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR
.entries()
.iter()
.filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false))
.collect();
entries.sort_by_key(|e| e.path());
let mut ran_migrations = 0;
for entry in &entries {
let filename = entry.path().file_name().unwrap().to_str().unwrap();
let version = filename.split('_').next().unwrap();
// Check if already applied
let already_applied: Option<i64> = pool
.get()?
.query_row("SELECT 1 FROM _blob_migrations WHERE version = ?", [version], |r| r.get(0))
.optional()?;
if already_applied.is_some() {
debug!("Skipping already applied blob migration: {}", filename);
continue;
}
let sql =
entry.as_file().unwrap().contents_utf8().expect("Failed to read blob migration file");
info!("Applying blob migration: {}", filename);
let conn = pool.get()?;
conn.execute_batch(sql)?;
// Record migration
conn.execute(
"INSERT INTO _blob_migrations (version, description) VALUES (?, ?)",
params![version, filename],
)?;
ran_migrations += 1;
}
if ran_migrations == 0 {
info!("No blob migrations to run");
} else {
info!("Ran {} blob migration(s)", ran_migrations);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
fn create_test_manager() -> BlobManager {
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
migrate_blob_db(&pool).unwrap();
BlobManager::new(pool.clone(), pool)
}
fn insert(manager: &BlobManager, chunks: &[BodyChunk]) {
manager
.with_tx(|b| {
for c in chunks {
b.insert_chunk(c)?;
}
Ok::<_, Error>(())
})
.unwrap();
}
#[test]
fn test_insert_and_get_chunks() {
let manager = create_test_manager();
let body_id = "rs_test123.request";
insert(
&manager,
&[
BodyChunk::new(body_id, 0, b"Hello, ".to_vec()),
BodyChunk::new(body_id, 1, b"World!".to_vec()),
],
);
let chunks = manager.connect().get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].chunk_index, 0);
assert_eq!(chunks[0].data, b"Hello, ");
assert_eq!(chunks[1].chunk_index, 1);
assert_eq!(chunks[1].data, b"World!");
}
#[test]
fn test_get_chunks_ordered_by_index() {
let manager = create_test_manager();
let body_id = "rs_test123.request";
// Insert out of order
insert(
&manager,
&[
BodyChunk::new(body_id, 2, b"C".to_vec()),
BodyChunk::new(body_id, 0, b"A".to_vec()),
BodyChunk::new(body_id, 1, b"B".to_vec()),
],
);
let chunks = manager.connect().get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].data, b"A");
assert_eq!(chunks[1].data, b"B");
assert_eq!(chunks[2].data, b"C");
}
#[test]
fn test_delete_chunks() {
let manager = create_test_manager();
let body_id = "rs_test123.request";
insert(&manager, &[BodyChunk::new(body_id, 0, b"data".to_vec())]);
assert!(manager.connect().body_exists(body_id).unwrap());
manager.with_tx(|b| b.delete_chunks(body_id)).unwrap();
let ctx = manager.connect();
assert!(!ctx.body_exists(body_id).unwrap());
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
}
#[test]
fn test_delete_chunks_like() {
let manager = create_test_manager();
// Insert chunks for same response but different body types
insert(
&manager,
&[
BodyChunk::new("rs_abc.request", 0, b"req".to_vec()),
BodyChunk::new("rs_abc.response", 0, b"resp".to_vec()),
BodyChunk::new("rs_other.request", 0, b"other".to_vec()),
],
);
// Delete all bodies for rs_abc
manager.with_tx(|b| b.delete_chunks_like("rs_abc.%")).unwrap();
let ctx = manager.connect();
assert!(!ctx.body_exists("rs_abc.request").unwrap());
assert!(!ctx.body_exists("rs_abc.response").unwrap());
assert!(ctx.body_exists("rs_other.request").unwrap());
}
#[test]
fn test_get_body_size() {
let manager = create_test_manager();
let body_id = "rs_test123.request";
insert(
&manager,
&[
BodyChunk::new(body_id, 0, b"Hello".to_vec()),
BodyChunk::new(body_id, 1, b"World".to_vec()),
],
);
let size = manager.connect().get_body_size(body_id).unwrap();
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
}
#[test]
fn test_get_body_size_empty() {
let manager = create_test_manager();
let size = manager.connect().get_body_size("nonexistent").unwrap();
assert_eq!(size, 0);
}
#[test]
fn test_body_exists() {
let manager = create_test_manager();
assert!(!manager.connect().body_exists("rs_test.request").unwrap());
insert(&manager, &[BodyChunk::new("rs_test.request", 0, b"data".to_vec())]);
assert!(manager.connect().body_exists("rs_test.request").unwrap());
}
#[test]
fn test_multiple_bodies_isolated() {
let manager = create_test_manager();
insert(
&manager,
&[
BodyChunk::new("body1", 0, b"data1".to_vec()),
BodyChunk::new("body2", 0, b"data2".to_vec()),
],
);
let ctx = manager.connect();
let chunks1 = ctx.get_chunks("body1").unwrap();
let chunks2 = ctx.get_chunks("body2").unwrap();
assert_eq!(chunks1.len(), 1);
assert_eq!(chunks1[0].data, b"data1");
assert_eq!(chunks2.len(), 1);
assert_eq!(chunks2[0].data, b"data2");
}
#[test]
fn test_large_chunk() {
let manager = create_test_manager();
// 1MB chunk
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
let body_id = "rs_large.request";
insert(&manager, &[BodyChunk::new(body_id, 0, large_data.clone())]);
let ctx = manager.connect();
let chunks = ctx.get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].data, large_data);
assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024);
}
}

View File

@@ -0,0 +1,176 @@
use crate::error::Result;
use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use rusqlite::params;
use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
use std::cell::RefCell;
use std::fmt::Debug;
use std::ops::Deref;
use yaak_database::DbContext;
/// A read handle. Comes from the reader pool and can only query.
///
/// Anything that changes a row lives on [`WriteDb`], which is only ever handed
/// out inside a transaction on the single writer connection. That split is
/// what keeps the pool from filling with writers waiting on each other: there
/// is one writer, so there is never a second one to wait for.
pub struct ClientDb<'a> {
pub(crate) ctx: DbContext<'a>,
}
impl<'a> ClientDb<'a> {
pub fn new(ctx: DbContext<'a>) -> Self {
Self { ctx }
}
/// Access the underlying connection for custom queries.
pub(crate) fn conn(&self) -> &yaak_database::ConnectionOrTx<'a> {
self.ctx.conn()
}
pub(crate) fn find_one<M>(
&self,
col: impl IntoColumnRef + IntoIden + Clone,
value: impl Into<SimpleExpr> + Debug,
) -> Result<M>
where
M: UpsertModelInfo,
{
Ok(self.ctx.find_one(col, value)?)
}
pub(crate) fn find_optional<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Option<M>
where
M: UpsertModelInfo,
{
self.ctx.find_optional(col, value)
}
pub(crate) fn find_all<M>(&self) -> Result<Vec<M>>
where
M: UpsertModelInfo,
{
Ok(self.ctx.find_all()?)
}
pub(crate) fn find_many<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
limit: Option<u64>,
) -> Result<Vec<M>>
where
M: UpsertModelInfo,
{
Ok(self.ctx.find_many(col, value, limit)?)
}
}
/// A write handle: a [`ClientDb`] on the writer connection, inside a
/// transaction, that can also change rows. Derefs to [`ClientDb`] so every
/// query is available while writing, and reads inside the transaction see
/// its own uncommitted writes.
///
/// Model events are held back until the transaction commits; a rollback
/// discards them along with the rows.
pub struct WriteDb<'a> {
db: ClientDb<'a>,
pending_events: RefCell<Vec<ModelPayload>>,
}
impl<'a> Deref for WriteDb<'a> {
type Target = ClientDb<'a>;
fn deref(&self) -> &ClientDb<'a> {
&self.db
}
}
impl<'a> WriteDb<'a> {
pub fn new(ctx: DbContext<'a>) -> Self {
Self { db: ClientDb::new(ctx), pending_events: RefCell::new(Vec::new()) }
}
/// The events for everything written so far, to send once the
/// transaction has committed.
pub(crate) fn into_events(self) -> Vec<ModelPayload> {
self.pending_events.into_inner()
}
/// Bulk-delete all rows matching a column value WITHOUT recording model
/// changes or emitting events. Only use for cascades whose deletion is
/// implied by a recorded parent delete (e.g. workspace children — see
/// [`ModelChangeEvent::Delete`]).
pub(crate) fn delete_many_untracked<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Result<usize>
where
M: UpsertModelInfo,
{
Ok(self.ctx.delete_many::<M>(col, value)?)
}
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + UpsertModelInfo + Clone,
{
let (m, created) = self.ctx.upsert(model, &source.to_db())?;
let payload = ModelPayload {
model: m.clone().into(),
update_source: source.clone(),
change: ModelChangeEvent::Upsert { created },
};
self.record_model_change(&payload)?;
self.pending_events.borrow_mut().push(payload);
Ok(m)
}
pub(crate) fn delete<M>(&self, m: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
self.ctx.delete(m)?;
let payload = ModelPayload {
model: m.clone().into(),
update_source: source.clone(),
change: ModelChangeEvent::Delete,
};
self.record_model_change(&payload)?;
self.pending_events.borrow_mut().push(payload);
Ok(m.clone())
}
fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {
let payload_json = serde_json::to_string(payload)?;
let source_json = serde_json::to_string(&payload.update_source)?;
let change_json = serde_json::to_string(&payload.change)?;
self.ctx.conn().resolve().execute(
r#"
INSERT INTO model_changes (model, model_id, change, update_source, payload)
VALUES (?1, ?2, ?3, ?4, ?5)
"#,
params![
payload.model.model(),
payload.model.id(),
change_json,
source_json,
payload_json,
],
)?;
Ok(())
}
}

View File

@@ -0,0 +1 @@
pub use yaak_database::connection_or_tx::ConnectionOrTx;

View File

@@ -0,0 +1,107 @@
//! Carrying a send's cookie changes back into a jar.
//!
//! A send starts from a snapshot of the jar and hands back the jar as the
//! transaction left it. Writing that whole result over the jar would also
//! write over anything the user changed *while* the send was in flight — a
//! cookie edited or deleted in the jar view, or set by another send. So the
//! send's contribution is taken as a difference (what it added, changed, or
//! removed relative to its snapshot) and applied to whatever the jar holds now.
use crate::models::{Cookie, CookieDomain};
/// The identity of a cookie in a jar: two cookies with the same name, domain
/// and path are the same cookie, whatever their value or attributes.
type CookieKey = (String, CookieDomain, String);
fn key(c: &Cookie) -> CookieKey {
(c.name.clone(), c.domain.clone(), c.path.clone())
}
/// Apply the changes between `before` (the snapshot a send started from) and
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
///
/// Cookies the send removed are removed; cookies it added or changed replace
/// their counterpart in `current`, or are appended. Cookies the send did not
/// touch are left exactly as `current` has them.
pub fn apply_cookie_changes(
current: Vec<Cookie>,
before: &[Cookie],
after: &[Cookie],
) -> Vec<Cookie> {
let removed: Vec<CookieKey> =
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
let mut result: Vec<Cookie> =
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
for cookie in changed {
match result.iter_mut().find(|c| key(c) == key(cookie)) {
Some(existing) => *existing = cookie.clone(),
None => result.push(cookie.clone()),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::CookieExpires;
fn cookie(name: &str, value: &str) -> Cookie {
Cookie {
name: name.to_string(),
value: value.to_string(),
domain: CookieDomain::HostOnly("example.com".to_string()),
expires: CookieExpires::SessionEnd,
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
}
}
#[test]
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
let before = vec![cookie("a", "1")];
let current = vec![cookie("a", "edited"), cookie("b", "2")];
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
}
#[test]
fn additions_and_changes_land_without_touching_concurrent_edits() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
// Meanwhile the user edited `a` and added `d`.
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![
cookie("a", "edited"),
cookie("b", "3"),
cookie("d", "5"),
cookie("c", "4")
]
);
}
#[test]
fn a_cookie_the_send_removed_is_removed() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("b", "2")];
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![cookie("b", "2"), cookie("c", "3")]
);
}
#[test]
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
let before = vec![cookie("a", "1")];
let after = vec![cookie("a", "1")]; // untouched by the send
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
let after = vec![cookie("a", "fresh")]; // the send set it again
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
}
}

View File

@@ -0,0 +1,69 @@
use serde::{Serialize, Serializer};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("SQL error: {0}")]
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] yaak_database::PoolError),
#[error("Database error: {0}")]
Database(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Model not found: {0}")]
ModelNotFound(String),
#[error("Model serialization error: {0}")]
ModelSerializationError(String),
#[error("HTTP error: {0}")]
GenericError(String),
#[error("DB Migration Failed: {0}")]
MigrationError(String),
#[error("No base environment for {0}")]
MissingBaseEnvironment(String),
#[error("Invalid environment selection: {0}")]
InvalidEnvironment(String),
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("unknown error")]
Unknown,
}
impl From<yaak_database::Error> for Error {
fn from(e: yaak_database::Error) -> Self {
match e {
yaak_database::Error::SqlError(e) => Error::SqlError(e),
yaak_database::Error::SqlPoolError(e) => Error::SqlPoolError(e),
yaak_database::Error::Database(s) => Error::Database(s),
yaak_database::Error::Io(e) => Error::Io(e),
yaak_database::Error::JsonError(e) => Error::JsonError(e),
yaak_database::Error::ModelNotFound(s) => Error::ModelNotFound(s),
yaak_database::Error::MigrationError(s) => Error::MigrationError(s),
}
}
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,104 @@
//! The example workspace offered during onboarding.
//!
//! Authored as JSON with `{{PLACEHOLDER}}` ids so requests can reference each
//! other (the chaining example names another request by id). Every placeholder
//! gets a fresh id per creation, so the example can be created more than once.
use crate::error::Result;
use crate::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
use crate::query_manager::QueryManager;
use crate::util::{BatchUpsertResult, UpdateSource};
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
const WORKSPACE_IDS: &[&str] = &["WORKSPACE"];
const ENVIRONMENT_IDS: &[&str] = &["ENV_BASE", "ENV_USER_2"];
const FOLDER_IDS: &[&str] = &[
"FOLDER_BASICS",
"FOLDER_VARIABLES",
"FOLDER_CHAINING",
"FOLDER_AUTH",
];
const REQUEST_IDS: &[&str] = &[
"RQ_LIST_POSTS",
"RQ_GET_POST",
"RQ_CREATE_POST",
"RQ_CURRENT_USER",
"RQ_LIST_USERS",
"RQ_POSTS_BY_FIRST_USER",
"RQ_TODOS",
"RQ_LOG_IN",
"RQ_ME",
];
pub fn create_example_workspace(
query_manager: &QueryManager,
source: &UpdateSource,
) -> Result<BatchUpsertResult> {
let resources = example_resources()?;
Ok(query_manager.with_tx(|tx| {
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
source,
)
})?)
}
fn example_resources() -> Result<BatchUpsertResult> {
let mut json = EXAMPLE_JSON.to_string();
let placeholders = WORKSPACE_IDS
.iter()
.map(|p| (*p, Workspace::generate_id()))
.chain(ENVIRONMENT_IDS.iter().map(|p| (*p, Environment::generate_id())))
.chain(FOLDER_IDS.iter().map(|p| (*p, Folder::generate_id())))
.chain(REQUEST_IDS.iter().map(|p| (*p, HttpRequest::generate_id())));
for (placeholder, id) in placeholders {
json = json.replace(&format!("{{{{{placeholder}}}}}"), &id);
}
debug_assert!(!json.contains("{{"), "example workspace has an unmapped placeholder");
Ok(serde_json::from_str(&json)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_in_memory;
#[test]
fn every_placeholder_is_mapped() {
let json = serde_json::to_string(&example_resources().unwrap()).unwrap();
assert!(!json.contains("{{"), "unmapped placeholder in {json}");
}
#[test]
fn chained_request_references_a_created_request() {
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
let created = create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
assert_eq!(created.workspaces.len(), 1);
let workspace_id = &created.workspaces[0].id;
let db = query_manager.connect();
let requests = db.list_http_requests(workspace_id).unwrap();
assert_eq!(requests.len(), REQUEST_IDS.len());
assert_eq!(db.list_folders(workspace_id).unwrap().len(), FOLDER_IDS.len());
assert_eq!(db.list_environments(workspace_id).unwrap().len(), ENVIRONMENT_IDS.len());
let list_users = requests.iter().find(|r| r.name == "List users").unwrap();
let chained = requests.iter().find(|r| r.name == "Posts by the first user").unwrap();
let param = &chained.url_parameters[0].value;
assert!(param.contains(&format!("request='{}'", list_users.id)), "{param}");
}
#[test]
fn creating_twice_makes_two_workspaces() {
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
assert_eq!(query_manager.connect().list_workspaces().unwrap().len(), 2);
}
}

View File

@@ -0,0 +1,181 @@
{
"workspaces": [
{
"id": "{{WORKSPACE}}",
"name": "Example Workspace",
"description": "A small tour of Yaak using the [Yaak Playground](https://yaak.run) API. Nothing here needs an account.\n\n1. Open **Getting Started / List posts** and press Send\n2. Open **Variables / Get the current user**, then switch the environment in the top-left to change which user it fetches\n3. Open **Chaining / Posts by the first user** to see how one request reads a value out of another request's response\n4. Open **Authentication / Get my profile** to see a login token used for you\n\nDelete this workspace whenever you're done with it."
}
],
"environments": [
{
"id": "{{ENV_BASE}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Global Variables",
"parentModel": "workspace",
"public": true,
"variables": [
{ "name": "base_url", "value": "https://yaak.run", "enabled": true },
{ "name": "user_id", "value": "1", "enabled": true },
{ "name": "username", "value": "ada", "enabled": true },
{ "name": "password", "value": "yaak", "enabled": true }
]
},
{
"id": "{{ENV_USER_2}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Another User",
"parentModel": "environment",
"public": true,
"color": "#c084fc",
"variables": [
{ "name": "user_id", "value": "2", "enabled": true },
{ "name": "username", "value": "grace", "enabled": true }
]
}
],
"folders": [
{
"id": "{{FOLDER_BASICS}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Getting Started",
"sortPriority": 1,
"description": "Plain requests. Send one, then look through the response tabs: Body, Headers, Cookies, and Timeline."
},
{
"id": "{{FOLDER_VARIABLES}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Variables",
"sortPriority": 2,
"description": "Anything wrapped in `${[ ... ]}` is a template. Variables come from the environment selected in the top-left, and sub-environments override the base values."
},
{
"id": "{{FOLDER_CHAINING}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Chaining",
"sortPriority": 3,
"description": "The `response()` template function reads a value out of another request's response, sending that request first if it has to. Put the cursor in a URL and press Ctrl+Space to insert one."
},
{
"id": "{{FOLDER_AUTH}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Authentication",
"sortPriority": 4,
"description": "This folder sets Bearer auth to the token from the **Log in** response. Every request inside inherits it, so the token lives in one place. When the last login is more than 50 minutes old, Yaak logs in again before sending.",
"authenticationType": "bearer",
"authentication": {
"token": "${[ response.body.path(request='{{RQ_LOG_IN}}', path='$.accessToken', behavior='ttl', ttl='3000') ]}"
}
}
],
"httpRequests": [
{
"id": "{{RQ_LIST_POSTS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "List posts",
"method": "GET",
"url": "${[ base_url ]}/posts",
"sortPriority": 1,
"urlParameters": [{ "name": "limit", "value": "5", "enabled": true }],
"description": "Query parameters live in the Params tab. Try raising `limit`."
},
{
"id": "{{RQ_GET_POST}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "Get a post",
"method": "GET",
"url": "${[ base_url ]}/posts/1",
"sortPriority": 2,
"description": "Change the ID at the end of the URL and send again."
},
{
"id": "{{RQ_CREATE_POST}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "Create a post",
"method": "POST",
"url": "${[ base_url ]}/posts",
"sortPriority": 3,
"bodyType": "application/json",
"body": {
"text": "{\n \"title\": \"Hello from Yaak\",\n \"body\": \"Templates work inside bodies too\",\n \"userId\": ${[ user_id ]}\n}"
},
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
"description": "Posts you create are only visible to you, and go back to the sample data after an hour. Put the new ID into **Get a post** to read it back."
},
{
"id": "{{RQ_CURRENT_USER}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_VARIABLES}}",
"name": "Get the current user",
"method": "GET",
"url": "${[ base_url ]}/users/${[ user_id ]}",
"sortPriority": 1,
"description": "Switch to the **Another User** environment in the top-left and send again. Hover a template in the URL to see what it resolves to."
},
{
"id": "{{RQ_TODOS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_VARIABLES}}",
"name": "List the user's todos",
"method": "GET",
"url": "${[ base_url ]}/users/${[ user_id ]}/todos",
"sortPriority": 2,
"urlParameters": [{ "name": "completed", "value": "false", "enabled": true }],
"description": "The same `user_id` variable, used in a different request. Disable the `completed` parameter to see finished todos too."
},
{
"id": "{{RQ_LIST_USERS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_CHAINING}}",
"name": "List users",
"method": "GET",
"url": "${[ base_url ]}/users",
"sortPriority": 1
},
{
"id": "{{RQ_POSTS_BY_FIRST_USER}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_CHAINING}}",
"name": "Posts by the first user",
"method": "GET",
"url": "${[ base_url ]}/posts",
"sortPriority": 2,
"urlParameters": [
{
"name": "userId",
"value": "${[ response.body.path(request='{{RQ_LIST_USERS}}', path='$[0].id') ]}",
"enabled": true
}
],
"description": "The `userId` parameter is pulled from the **List users** response with the JSONPath `$[0].id`. If that request hasn't been sent yet, Yaak sends it first."
},
{
"id": "{{RQ_LOG_IN}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_AUTH}}",
"name": "Log in",
"method": "POST",
"url": "${[ base_url ]}/auth/token",
"sortPriority": 1,
"authenticationType": "none",
"bodyType": "application/json",
"body": {
"text": "{\n \"username\": \"${[ username ]}\",\n \"password\": \"${[ password ]}\"\n}"
},
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
"description": "Sends the username and password from the environment and returns a token. Its Auth tab is set to No Auth, so it doesn't try to use the token it's fetching."
},
{
"id": "{{RQ_ME}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_AUTH}}",
"name": "Get my profile",
"method": "GET",
"url": "${[ base_url ]}/auth/me",
"sortPriority": 2,
"description": "Open the Auth tab: it inherits Bearer auth from the folder, and there's nothing to copy and paste. Check the Timeline tab after sending to see the Authorization header that was sent. To try it as someone else, switch to **Another User** and send **Log in** again."
}
]
}

View File

@@ -0,0 +1,33 @@
//! Building an export document.
//!
//! Here rather than in `yaak` because the whole of it is this crate: a connection, the
//! resources, and JSON. Putting it where the browser can reach it too is what keeps a
//! tab's export from being a second implementation that drifts.
use crate::error::Result;
use crate::query_manager::QueryManager;
use crate::util::get_workspace_export_resources;
pub struct ExportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub yaak_version: &'a str,
pub workspace_ids: Vec<&'a str>,
pub include_private_environments: bool,
}
/// The export document, as JSON.
///
/// Returned rather than written: where an export goes is the host's to decide, and a browser
/// tab has no path to be handed. The desktop hands the bytes to its save dialog, the CLI
/// writes them, a tab downloads them. None of that is this function's business.
pub fn export_data(params: ExportDataParams<'_>) -> Result<String> {
let db = params.query_manager.connect();
let export_data = get_workspace_export_resources(
&db,
params.yaak_version,
params.workspace_ids,
params.include_private_environments,
)?;
Ok(serde_json::to_string_pretty(&export_data)?)
}

View File

@@ -0,0 +1,183 @@
use crate::blob_manager::{BlobManager, migrate_blob_db};
use crate::error::Result;
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelPayload;
use log::info;
use std::path::Path;
use std::sync::mpsc;
use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
mod connection_or_tx;
pub mod cookies;
pub mod error;
pub mod example;
pub mod export;
pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod path_placeholders;
pub mod queries;
pub mod query_manager;
pub mod render;
pub mod util;
/// Per-connection setup, applied by every pool on every connection it opens.
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.busy_timeout(std::time::Duration::from_millis(5000))
}
#[cfg(not(target_arch = "wasm32"))]
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
init_connection(conn)
}
/// The two ways a pool comes to exist, one per target.
///
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
/// connection over whatever VFS the host registered before calling in — the
/// path is a name inside that VFS, not a place on disk. Everything downstream
/// of `SqlitePool` is target-agnostic; this is the only fork.
#[cfg(not(target_arch = "wasm32"))]
mod open {
use super::*;
use crate::error::Error;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
use std::time::Duration;
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
let path: PathBuf = path.into();
// Create parent directories if needed
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
Pool::builder()
.max_size(max_size)
.min_idle(Some(min_idle))
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))
}
/// `(readers, writer)` over one file: a pool of `max_size` readers and a
/// pool of exactly one writer.
pub fn file_pools(
path: impl Into<PathBuf>,
max_size: u32,
min_idle: u32,
) -> Result<(SqlitePool, SqlitePool)> {
let path: PathBuf = path.into();
Ok((file_pool(&path, max_size, min_idle)?, file_pool(&path, 1, 1)?))
}
pub fn memory_pool() -> Result<SqlitePool> {
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
// In-memory DB doesn't support multiple connections
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
}
}
#[cfg(target_arch = "wasm32")]
mod open {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
pub fn file_pool(
path: impl Into<PathBuf>,
_max_size: u32,
_min_idle: u32,
) -> Result<SqlitePool> {
// No WAL: the browser VFSs are single-connection and journal their own
// way; the pragma is accepted and ignored on some and rejected on
// others, so it is not applied at all here.
let conn = Connection::open(path.into())?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
/// One connection is all a browser VFS allows, so it reads and writes.
pub fn file_pools(
path: impl Into<PathBuf>,
max_size: u32,
min_idle: u32,
) -> Result<(SqlitePool, SqlitePool)> {
let pool = file_pool(path, max_size, min_idle)?;
Ok((pool.clone(), pool))
}
pub fn memory_pool() -> Result<SqlitePool> {
let conn = Connection::open_in_memory()?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
}
/// Initialize the database managers for standalone (non-Tauri) usage.
///
/// Returns a tuple of (QueryManager, BlobManager, event_receiver).
/// The event_receiver can be used to listen for model change events.
pub fn init_standalone(
db_path: impl AsRef<Path>,
blob_path: impl AsRef<Path>,
) -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
let db_path = db_path.as_ref();
let blob_path = blob_path.as_ref();
// Each database gets a reader pool and a one-connection writer pool; see
// `QueryManager` for why. Reader pools are sized for concurrent in-flight
// queries, not concurrent app features — connections are held per-statement,
// so even heavy fan-out (e.g. many gRPC streams) only needs a handful at once.
// Keep them modest: WAL connections hold ~3 file descriptors each, and macOS
// GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}");
let (readers, writer) = open::file_pools(db_path, 20, 2)?;
migrate_db(&writer)?;
info!("Initializing blobs database {blob_path:?}");
let (blob_readers, blob_writer) = open::file_pools(blob_path, 10, 1)?;
migrate_blob_db(&blob_writer)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(readers, writer, tx);
let blob_manager = BlobManager::new(blob_readers, blob_writer);
bootstrap(&query_manager)?;
Ok((query_manager, blob_manager, rx))
}
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
///
/// An in-memory database is private to its connection, so the one connection
/// is both the reader pool and the writer.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
let pool = open::memory_pool()?;
migrate_db(&pool)?;
let blob_pool = open::memory_pool()?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(pool.clone(), pool, tx);
let blob_manager = BlobManager::new(blob_pool.clone(), blob_pool);
bootstrap(&query_manager)?;
Ok((query_manager, blob_manager, rx))
}
/// The rows every client assumes exist. Workspaces are not among them: a fresh
/// install has none, and the client shows onboarding until the user makes one.
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
query_manager.with_tx(|tx| {
tx.ensure_settings()?;
Ok(())
})
}

View File

@@ -0,0 +1,147 @@
use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::{debug, info};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use sha2::{Digest, Sha384};
use yaak_database::SqlitePool;
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
info!("Running database migrations");
// Ensure the table exists
// NOTE: Yaak used to use sqlx for migrations, so we need to mirror that table structure. We
// are writing checksum but not verifying because we want to be able to change migrations after
// a release in case something breaks.
pool.get()?.execute(
"CREATE TABLE IF NOT EXISTS _sqlx_migrations (
version BIGINT PRIMARY KEY,
description TEXT NOT NULL,
installed_on TIMESTAMP default CURRENT_TIMESTAMP NOT NULL,
success BOOLEAN NOT NULL,
checksum BLOB NOT NULL,
execution_time BIGINT NOT NULL
)",
[],
)?;
// Read and sort all .sql files
let mut entries = MIGRATIONS_DIR
.entries()
.into_iter()
.filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false))
.collect::<Vec<_>>();
// Ensure they're in the correct order
entries.sort_by_key(|e| e.path());
// Run each migration in a transaction
let mut num_migrations = 0;
let mut ran_migrations = 0;
for entry in entries {
num_migrations += 1;
let conn = pool.get()?;
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
match run_migration(entry, &mut tx) {
Ok(ran) => {
if ran {
ran_migrations += 1;
}
tx.commit()?
}
Err(e) => {
let msg = format!(
"{} failed with {}",
entry.path().file_name().unwrap().to_str().unwrap(),
e.to_string()
);
tx.rollback()?;
return Err(MigrationError(msg));
}
};
}
if ran_migrations == 0 {
info!("No migrations to run out of {}", num_migrations);
} else {
info!("Ran {}/{} migrations", ran_migrations, num_migrations);
}
Ok(())
}
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
let start = elapsed_timer();
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
.expect("Failed to parse migration filename");
// Skip if already applied
let row: Option<i64> = tx
.query_row("SELECT 1 FROM _sqlx_migrations WHERE version = ?", [version.clone()], |r| {
r.get(0)
})
.optional()?;
if row.is_some() {
debug!("Skipping already run migration {description}");
return Ok(false); // Migration was already run
}
let sql =
migration_path.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
info!("Applying migration {description}");
// Split on `;`? → optional depending on how your SQL is structured
tx.execute_batch(&sql)?;
let execution_time = start();
let checksum = sha384_hex_prefixed(sql.as_bytes());
// NOTE: The success column is never used. It's just there for sqlx compatibility.
tx.execute(
"INSERT INTO _sqlx_migrations (version, description, execution_time, checksum, success) VALUES (?, ?, ?, ?, ?)",
params![version, description, execution_time, checksum, true],
)?;
Ok(true)
}
/// Nanoseconds since the timer was started, for the sqlx-compatible
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
/// there it reads as zero rather than taking the migrator down with it.
#[cfg(not(target_arch = "wasm32"))]
fn elapsed_timer() -> impl Fn() -> i64 {
let start = std::time::Instant::now();
move || start.elapsed().as_nanos() as i64
}
#[cfg(target_arch = "wasm32")]
fn elapsed_timer() -> impl Fn() -> i64 {
|| 0
}
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
// Remove the .sql extension
let trimmed = filename.strip_suffix(".sql")?;
// Split on the first underscore
let mut parts = trimmed.splitn(2, '_');
let version = parts.next()?.to_string();
let description = parts.next()?.to_string();
Some((version, description))
}
fn sha384_hex_prefixed(input: &[u8]) -> String {
let mut hasher = Sha384::new();
hasher.update(input);
let result = hasher.finalize();
// Format as 0x... with uppercase hex
format!("0x{}", hex::encode_upper(result))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
//! Generic model writes, shared by every host.
//!
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
//! typed query for its variant. That fan-out is long, mechanical, and has to
//! grow a new arm every time a model is added — exactly the code that should
//! not exist twice. The host supplies the database handles and the
//! `UpdateSource` identifying who is writing; nothing here knows whether the
//! caller is a desktop window or an HTTP request.
use crate::blob_manager::BlobManager;
use crate::client_db::WriteDb;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models::AnyModel;
use crate::util::UpdateSource;
pub fn upsert_model(
db: &WriteDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
};
Ok(id)
}
/// Deletes cascade, so callers run this inside a transaction.
pub fn delete_model(
tx: &WriteDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
}
/// Duplicates recurse, so callers run this inside a transaction.
///
/// The model is re-read from the database rather than taken from the caller, so
/// a duplicate never comes from a stale frontend snapshot.
pub fn duplicate_model(
tx: &WriteDb,
model_type: &str,
model_id: &str,
source: &UpdateSource,
) -> Result<String> {
let id = match model_type {
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
}

View File

@@ -0,0 +1,220 @@
use crate::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
parameters: &Vec<HttpUrlParameter>,
) -> (String, Vec<HttpUrlParameter>) {
let mut new_parameters = Vec::new();
let mut url = url.to_string();
for p in parameters {
if !p.enabled || p.name.is_empty() {
continue;
}
// Replace path parameters with values from URL parameters
let old_url_string = url.clone();
url = replace_path_placeholder(&p, url.as_str());
// Remove as param if it modified the URL
if old_url_string == *url {
new_parameters.push(p.to_owned());
}
}
(url, new_parameters)
}
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
if !p.name.starts_with(":") {
return url.to_string();
}
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
//
// A plain scan rather than a regex: the name is matched literally, so a name containing
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
let name = p.name.as_str();
let value = urlencoding::encode(p.value.as_str());
let mut result = String::with_capacity(url.len());
let mut rest = url;
while let Some(slash) = rest.find('/') {
let after_slash = &rest[slash + 1..];
let is_placeholder = after_slash.starts_with(name)
&& after_slash[name.len()..]
.chars()
.next()
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
if is_placeholder {
result.push_str(&rest[..=slash]);
result.push_str(&value);
rest = &after_slash[name.len()..];
} else {
result.push_str(&rest[..=slash]);
rest = after_slash;
}
}
result.push_str(rest);
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::models::{HttpRequest, HttpUrlParameter};
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
#[test]
fn placeholder_middle() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
"https://example.com/xxx/bar",
);
}
#[test]
fn placeholder_end() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/xxx",
);
}
#[test]
fn placeholder_query() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
"https://example.com/xxx?:foo",
);
}
#[test]
fn placeholder_followed_by_literal_colon() {
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
// is literal text in the same path segment.
let p =
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
"https://example.com/tasks/42:increment-importance",
);
}
#[test]
fn placeholder_name_is_matched_literally() {
// `.` in a name is a dot, not "any character".
let p = HttpUrlParameter {
name: ":id.v2".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
"https://example.com/xxx/:idXv2",
);
}
#[test]
fn placeholder_repeated() {
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id/:id"),
"https://example.com/7/7",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
enabled: true,
name: "".to_string(),
value: "".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:missing"),
"https://example.com/:missing",
);
}
#[test]
fn placeholder_disabled() {
let p = HttpUrlParameter {
enabled: false,
name: ":foo".to_string(),
value: "xxx".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/:foo",
);
}
#[test]
fn placeholder_prefix() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foooo"),
"https://example.com/:foooo",
);
}
#[test]
fn placeholder_encode() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "Hello World".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/Hello%20World",
);
}
#[test]
fn apply_placeholder() {
let req = HttpRequest {
url: "example.com/:a/bar".to_string(),
url_parameters: vec![
HttpUrlParameter {
name: "b".to_string(),
value: "bbb".to_string(),
enabled: true,
id: None,
},
HttpUrlParameter {
name: ":a".to_string(),
value: "aaa".to_string(),
enabled: true,
id: None,
},
],
..Default::default()
};
let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters);
// Pattern match back to access it
assert_eq!(url, "example.com/aaa/bar");
assert_eq!(url_parameters.len(), 1);
assert_eq!(url_parameters[0].name, "b");
assert_eq!(url_parameters[0].value, "bbb");
}
}

View File

@@ -0,0 +1,21 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
pub enum AnyRequest {
HttpRequest(HttpRequest),
GrpcRequest(GrpcRequest),
WebsocketRequest(WebsocketRequest),
}
impl<'a> ClientDb<'a> {
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
if let Ok(http_request) = self.get_http_request(id) {
Ok(AnyRequest::HttpRequest(http_request))
} else if let Ok(grpc_request) = self.get_grpc_request(id) {
Ok(AnyRequest::GrpcRequest(grpc_request))
} else {
Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?))
}
}
}

View File

@@ -0,0 +1,73 @@
use crate::client_db::WriteDb;
use crate::error::Result;
use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
use crate::util::{BatchUpsertResult, UpdateSource};
use log::info;
impl<'a> WriteDb<'a> {
pub fn batch_upsert(
&self,
workspaces: Vec<Workspace>,
environments: Vec<Environment>,
folders: Vec<Folder>,
http_requests: Vec<HttpRequest>,
grpc_requests: Vec<GrpcRequest>,
websocket_requests: Vec<WebsocketRequest>,
source: &UpdateSource,
) -> Result<BatchUpsertResult> {
let mut imported_resources = BatchUpsertResult::default();
if workspaces.len() > 0 {
for v in workspaces {
let x = self.upsert_workspace(&v, source)?;
imported_resources.workspaces.push(x.clone());
}
info!("Upserted {} workspaces", imported_resources.workspaces.len());
}
if http_requests.len() > 0 {
for v in http_requests {
let x = self.upsert_http_request(&v, source)?;
imported_resources.http_requests.push(x.clone());
}
info!("Upserted Imported {} http_requests", imported_resources.http_requests.len());
}
if grpc_requests.len() > 0 {
for v in grpc_requests {
let x = self.upsert_grpc_request(&v, source)?;
imported_resources.grpc_requests.push(x.clone());
}
info!("Upserted {} grpc_requests", imported_resources.grpc_requests.len());
}
if websocket_requests.len() > 0 {
for v in websocket_requests {
let x = self.upsert_websocket_request(&v, source)?;
imported_resources.websocket_requests.push(x.clone());
}
info!("Upserted {} websocket_requests", imported_resources.websocket_requests.len());
}
// Do folders after their children so the UI doesn't render empty folders before populating
// immediately after.
if folders.len() > 0 {
for v in folders {
let x = self.upsert_folder(&v, source)?;
imported_resources.folders.push(x.clone());
}
info!("Upserted {} folders", imported_resources.folders.len());
}
// Do environments last because they can depend on many models (requests, folders, etc)
if environments.len() > 0 {
for x in environments {
let x = self.upsert_environment(&x, source)?;
imported_resources.environments.push(x.clone());
}
info!("Upserted {} environments", imported_resources.environments.len());
}
Ok(imported_resources)
}
}

View File

@@ -0,0 +1,50 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{CookieJar, CookieJarIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_cookie_jar(&self, id: &str) -> Result<CookieJar> {
self.find_one(CookieJarIden::Id, id)
}
pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {
self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)
}
}
impl<'a> WriteDb<'a> {
/// A workspace with no cookie jar gets a default one.
pub fn ensure_default_cookie_jar(&self, workspace_id: &str) -> Result<()> {
if self.list_cookie_jars(workspace_id)?.is_empty() {
let jar = CookieJar {
name: "Default".to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
};
self.upsert_cookie_jar(&jar, &UpdateSource::Background)?;
}
Ok(())
}
pub fn delete_cookie_jar(
&self,
cookie_jar: &CookieJar,
source: &UpdateSource,
) -> Result<CookieJar> {
self.delete(cookie_jar, source)
}
pub fn delete_cookie_jar_by_id(&self, id: &str, source: &UpdateSource) -> Result<CookieJar> {
let cookie_jar = self.get_cookie_jar(id)?;
self.delete_cookie_jar(&cookie_jar, source)
}
pub fn upsert_cookie_jar(
&self,
cookie_jar: &CookieJar,
source: &UpdateSource,
) -> Result<CookieJar> {
self.upsert(cookie_jar, source)
}
}

View File

@@ -0,0 +1,56 @@
/// Compute a name for a duplicated model that doesn't conflict with any sibling
/// name, following the " Copy N" convention. Empty names are kept empty so the
/// display falls back to the URL.
pub(crate) fn conflict_free_name(name: &str, sibling_names: &[String]) -> String {
if name.is_empty() {
return String::new();
}
let mut name = name.to_string();
for _ in 0..100 {
if !sibling_names.contains(&name) {
break;
}
name = next_copy_name(&name);
}
name
}
fn next_copy_name(name: &str) -> String {
if let Some(base) = name.strip_suffix(" Copy") {
return format!("{base} Copy 2");
}
if let Some(idx) = name.rfind(" Copy ") {
let n = &name[idx + " Copy ".len()..];
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = n.parse::<u64>() {
return format!("{} Copy {}", &name[..idx], n + 1);
}
}
}
format!("{name} Copy")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_next_copy_name() {
assert_eq!(next_copy_name("Foo"), "Foo Copy");
assert_eq!(next_copy_name("Foo Copy"), "Foo Copy 2");
assert_eq!(next_copy_name("Foo Copy 2"), "Foo Copy 3");
assert_eq!(next_copy_name("Foo Copy 99"), "Foo Copy 100");
assert_eq!(next_copy_name("Copy"), "Copy Copy");
}
#[test]
fn test_conflict_free_name() {
let siblings = vec!["Foo".to_string(), "Foo Copy".to_string(), "".to_string()];
assert_eq!(conflict_free_name("Foo", &siblings), "Foo Copy 2");
assert_eq!(conflict_free_name("Bar", &siblings), "Bar");
assert_eq!(conflict_free_name("", &siblings), "");
}
}

View File

@@ -0,0 +1,362 @@
use super::conflict_free_name;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource;
use log::{info, warn};
impl<'a> ClientDb<'a> {
pub fn get_environment(&self, id: &str) -> Result<Environment> {
self.find_one(EnvironmentIden::Id, id)
}
/// Resolve an explicit execution environment, rejecting foreign and folder environments.
/// Folder variables are inherited from the request's folder, never selected globally.
pub fn get_environment_for_workspace(
&self,
workspace_id: &str,
environment_id: &str,
) -> Result<Environment> {
let environment = self.get_environment(environment_id)?;
if environment.workspace_id != workspace_id {
return Err(crate::error::Error::InvalidEnvironment(format!(
"Environment {environment_id} does not belong to workspace {workspace_id}"
)));
}
if !matches!(environment.parent_model.as_str(), "workspace" | "environment") {
return Err(crate::error::Error::InvalidEnvironment(format!(
"Environment {environment_id} is not a workspace environment"
)));
}
Ok(environment)
}
pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {
let mut environments: Vec<Environment> =
self.find_many(EnvironmentIden::ParentId, folder_id, None)?;
// Sort so we return the most recently updated environment
environments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(environments.get(0).cloned())
}
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
let environments = self.list_environments(workspace_id)?;
let base_environments = environments
.into_iter()
.filter(|e| e.parent_model == "workspace")
.collect::<Vec<Environment>>();
if base_environments.len() > 1 {
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
}
Ok(base_environments
.first()
.cloned()
.ok_or(MissingBaseEnvironment(workspace_id.to_string()))?)
}
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
}
/// Find other environments with the same parent folder
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
if environment.parent_model != "folder" {
return Vec::new();
}
self.list_environments(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
e.id != environment.id
&& e.parent_model == "folder"
&& e.parent_id == environment.parent_id
})
.collect()
}
pub fn resolve_environments(
&self,
workspace_id: &str,
folder_id: Option<&str>,
active_environment_id: Option<&str>,
) -> Result<Vec<Environment>> {
let mut environments = Vec::new();
if let Some(folder_id) = folder_id {
let folder = self.get_folder(folder_id)?;
// Add current folder's environment
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
environments.push(e);
};
// Recurse up
let ancestors = self.resolve_environments(
workspace_id,
folder.folder_id.as_deref(),
active_environment_id,
)?;
environments.extend(ancestors);
} else {
// Add active and base environments
if let Some(id) = active_environment_id {
if let Ok(e) = self.get_environment(&id) {
// Add active sub environment
environments.push(e);
};
};
// Add the base environment. A workspace that has never been
// opened has none yet; it simply contributes no variables.
match self.get_base_environment(workspace_id) {
Ok(e) => environments.push(e),
Err(MissingBaseEnvironment(_)) => {}
Err(e) => return Err(e),
}
}
Ok(environments)
}
}
impl<'a> WriteDb<'a> {
/// The workspace's base environment, created if it does not exist.
pub fn ensure_base_environment(&self, workspace_id: &str) -> Result<Environment> {
match self.get_base_environment(workspace_id) {
Err(MissingBaseEnvironment(_)) => {}
other => return other,
}
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
&UpdateSource::Background,
)?;
info!("Created base environment {} for {workspace_id}", e.id);
Ok(e)
}
pub fn delete_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let deleted_environment = self.delete(environment, source)?;
// Recreate the base environment if we happened to delete it
self.ensure_base_environment(&environment.workspace_id)?;
Ok(deleted_environment)
}
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
let environment = self.get_environment(id)?;
self.delete_environment(&environment, source)
}
pub fn duplicate_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let mut environment = environment.clone();
environment.id = "".to_string();
let sibling_names = self
.list_environments(&environment.workspace_id)?
.into_iter()
.map(|e| e.name)
.collect::<Vec<_>>();
environment.name = conflict_free_name(&environment.name, &sibling_names);
self.upsert_environment(&environment, source)
}
pub fn upsert_environment(
&self,
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
let cleaned_variables = environment
.variables
.iter()
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
// Sometimes a new environment can be created via sync/import, so we'll just delete
// the others when that happens. Not the best, but it's good for now.
let duplicates = self.list_duplicate_folder_environments(environment);
for duplicate in duplicates {
warn!(
"Deleting duplicate environment {} for folder {:?}",
duplicate.id, environment.parent_id
);
_ = self.delete(&duplicate, source);
}
// Automatically update the environment name based on the folder name
let mut name = environment.name.clone();
match (environment.parent_model.as_str(), environment.parent_id.as_deref()) {
("folder", Some(folder_id)) => {
if let Ok(folder) = self.get_folder(folder_id) {
name = format!("{} Environment", folder.name);
}
}
_ => {}
}
self.upsert(
&Environment { name, variables: cleaned_variables, ..environment.clone() },
source,
)
}
}
#[cfg(test)]
mod tests {
use crate::error::Error;
use crate::init_in_memory;
use crate::models::{Environment, EnvironmentVariable, Folder, Workspace};
use crate::query_manager::QueryManager;
use crate::render::make_vars_hashmap;
use crate::util::UpdateSource;
fn fixture() -> (QueryManager, Environment, Environment, Environment) {
let (manager, _blobs, _rx) = init_in_memory().unwrap();
let source = &UpdateSource::Background;
let (base, staging, folder) = manager
.with_tx(|db| {
// Fresh databases no longer come with a workspace, so make one
let workspace = db.upsert_workspace(
&Workspace { name: "Test".into(), ..Default::default() },
source,
)?;
let variable = |name: &str, value: &str| EnvironmentVariable {
enabled: true,
name: name.into(),
value: value.into(),
..Default::default()
};
let base = db.ensure_base_environment(&workspace.id)?;
let base = db.upsert_environment(
&Environment {
variables: vec![
variable("marker", "global"),
variable("global_only", "inherited"),
],
..base
},
source,
)?;
let staging = db.upsert_environment(
&Environment {
workspace_id: workspace.id.clone(),
parent_model: "environment".into(),
parent_id: Some(base.id.clone()),
name: "Staging".into(),
variables: vec![variable("marker", "staging")],
..Default::default()
},
source,
)?;
let folder = db.upsert_folder(
&Folder {
workspace_id: workspace.id.clone(),
name: "Folder".into(),
..Default::default()
},
source,
)?;
let folder = db.upsert_environment(
&Environment {
workspace_id: workspace.id,
parent_model: "folder".into(),
parent_id: Some(folder.id),
variables: vec![variable("marker", "folder")],
..Default::default()
},
source,
)?;
Ok::<_, Error>((base, staging, folder))
})
.unwrap();
(manager, base, staging, folder)
}
#[test]
fn explicit_environment_preserves_global_and_folder_inheritance() {
let (manager, base, staging, folder) = fixture();
let db = manager.connect();
let selected = db.get_environment_for_workspace(&base.workspace_id, &staging.id).unwrap();
let vars = make_vars_hashmap(
db.resolve_environments(&base.workspace_id, None, Some(&selected.id)).unwrap(),
);
assert_eq!(vars["marker"], "staging");
assert_eq!(vars["global_only"], "inherited");
let vars = make_vars_hashmap(
db.resolve_environments(
&base.workspace_id,
folder.parent_id.as_deref(),
Some(&selected.id),
)
.unwrap(),
);
assert_eq!(vars["marker"], "folder");
assert_eq!(vars["global_only"], "inherited");
}
#[test]
fn base_environment_can_be_selected_explicitly() {
let (manager, base, _staging, _folder) = fixture();
let db = manager.connect();
let selected = db.get_environment_for_workspace(&base.workspace_id, &base.id).unwrap();
let vars = make_vars_hashmap(
db.resolve_environments(&base.workspace_id, None, Some(&selected.id)).unwrap(),
);
assert_eq!(vars["marker"], "global");
}
#[test]
fn explicit_environment_rejects_empty_and_missing_ids() {
let (manager, base, _staging, _folder) = fixture();
for id in ["", "ev_missing"] {
assert!(matches!(
manager.connect().get_environment_for_workspace(&base.workspace_id, id),
Err(Error::ModelNotFound(_))
));
}
}
#[test]
fn explicit_environment_rejects_another_workspace() {
let (manager, _base, staging, _folder) = fixture();
let other = manager
.with_tx(|db| {
db.upsert_workspace(
&Workspace { name: "Other".into(), ..Default::default() },
&UpdateSource::Background,
)
})
.unwrap();
assert!(matches!(
manager.connect().get_environment_for_workspace(&other.id, &staging.id),
Err(Error::InvalidEnvironment(_))
));
}
#[test]
fn explicit_environment_rejects_folder_variables() {
let (manager, base, _staging, folder) = fixture();
assert!(matches!(
manager.connect().get_environment_for_workspace(&base.workspace_id, &folder.id),
Err(Error::InvalidEnvironment(_))
));
}
}

View File

@@ -0,0 +1,223 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::{ClientDb, WriteDb};
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
use crate::models::{
AnyModel, Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden,
HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings, ResolvedSetting,
WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> ClientDb<'a> {
pub fn get_folder(&self, id: &str) -> Result<Folder> {
self.find_one(FolderIden::Id, id)
}
pub fn list_folders(&self, workspace_id: &str) -> Result<Vec<Folder>> {
self.find_many(FolderIden::WorkspaceId, workspace_id, None)
}
pub fn resolve_auth_for_folder(
&self,
folder: &Folder,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = folder.authentication_type.clone() {
return Ok((Some(at), folder.authentication.clone(), folder.id.clone()));
}
if let Some(folder_id) = folder.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&folder.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result<Vec<HttpRequestHeader>> {
let mut headers = Vec::new();
if let Some(folder_id) = folder.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
// NOTE: Add parent headers first, so overrides are logical
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&folder.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
Ok(merge_headers(headers, folder.headers.clone()))
}
pub fn resolve_settings_for_folder(
&self,
folder: &Folder,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = folder.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&parent_folder)?
} else {
let workspace = self.get_workspace(&folder.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if folder.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
folder.setting_validate_certificates.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.validate_certificates
},
follow_redirects: if folder.setting_follow_redirects.enabled {
ResolvedSetting::from_model(
folder.setting_follow_redirects.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.follow_redirects
},
request_timeout: if folder.setting_request_timeout.enabled {
ResolvedSetting::from_model(
folder.setting_request_timeout.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_timeout
},
request_message_size: if folder.setting_request_message_size.enabled {
ResolvedSetting::from_model(
folder.setting_request_message_size.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if folder.setting_send_cookies.enabled {
ResolvedSetting::from_model(
folder.setting_send_cookies.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.send_cookies
},
store_cookies: if folder.setting_store_cookies.enabled {
ResolvedSetting::from_model(
folder.setting_store_cookies.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.store_cookies
},
http_version: if folder.setting_http_version.enabled {
ResolvedSetting::from_model(
folder.setting_http_version.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.http_version
},
})
}
}
impl<'a> WriteDb<'a> {
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
match self.conn() {
ConnectionOrTx::Connection(_) => {}
ConnectionOrTx::Transaction(_) => {}
}
let fid = &folder.id;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.delete_websocket_request(&m, source)?;
}
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
self.delete_environment(&e, source)?;
}
// Recurse down into child folders
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
self.delete_folder(&folder, source)?;
}
self.delete(folder, source)
}
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
let folder = self.get_folder(id)?;
self.delete_folder(&folder, source)
}
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
self.upsert(folder, source)
}
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id;
let mut folder = Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
};
let sibling_names = self
.list_folders(&folder.workspace_id)?
.into_iter()
.filter(|f| f.folder_id == folder.folder_id)
.map(|f| f.name)
.collect::<Vec<_>>();
folder.name = conflict_free_name(&folder.name, &sibling_names);
let new_folder = self.upsert_folder(&folder, source)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request(
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.upsert_websocket_request(
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.upsert_grpc_request(
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
self.upsert_environment(
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
// Recurse down
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
}
Ok(new_folder)
}
}

View File

@@ -0,0 +1,54 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
use chrono::{Duration, Utc};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_graphql_introspection(
&self,
workspace_id: &str,
request_id: &str,
content: Option<String>,
source: &UpdateSource,
) -> Result<GraphQlIntrospection> {
// Clean up old ones every time a new one is upserted
self.delete_expired_graphql_introspections()?;
match self.get_graphql_introspection(request_id) {
None => self.upsert(
&GraphQlIntrospection {
content,
request_id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
source,
),
Some(introspection) => {
self.upsert(&GraphQlIntrospection { content, ..introspection }, source)
}
}
}
pub fn delete_expired_graphql_introspections(&self) -> Result<()> {
let cutoff = Utc::now().naive_utc() - Duration::days(7);
let (sql, params) = Query::delete()
.from_table(GraphQlIntrospectionIden::Table)
.cond_where(Expr::col(GraphQlIntrospectionIden::UpdatedAt).lt(cutoff))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().resolve().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
}

View File

@@ -0,0 +1,97 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn get_grpc_connection(&self, id: &str) -> Result<GrpcConnection> {
self.find_one(GrpcConnectionIden::Id, id)
}
pub fn list_grpc_connections_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
}
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn delete_all_grpc_connections_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_grpc_connections_for_request(request_id, None)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_all_grpc_connections_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
for m in self.list_grpc_connections(workspace_id)? {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_grpc_connection(
&self,
m: &GrpcConnection,
source: &UpdateSource,
) -> Result<GrpcConnection> {
self.delete(m, source)
}
pub fn delete_grpc_connection_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcConnection> {
let grpc_connection = self.get_grpc_connection(id)?;
self.delete_grpc_connection(&grpc_connection, source)
}
pub fn cancel_pending_grpc_connections(&self) -> Result<()> {
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
let (sql, params) = Query::update()
.table(GrpcConnectionIden::Table)
.values([(GrpcConnectionIden::State, closed.as_str().into())])
.cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
pub fn upsert_grpc_connection(
&self,
grpc_connection: &GrpcConnection,
source: &UpdateSource,
) -> Result<GrpcConnection> {
let connections =
self.list_grpc_connections_for_request(grpc_connection.request_id.as_str(), None)?;
for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old gRPC connection {}", grpc_connection.id);
self.delete_grpc_connection(&m, source)?;
}
self.upsert(grpc_connection, source)
}
}

View File

@@ -0,0 +1,24 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GrpcEvent, GrpcEventIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_grpc_events(&self, id: &str) -> Result<GrpcEvent> {
self.find_one(GrpcEventIden::Id, id)
}
pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {
self.find_many(GrpcEventIden::ConnectionId, connection_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_grpc_event(
&self,
grpc_event: &GrpcEvent,
source: &UpdateSource,
) -> Result<GrpcEvent> {
self.upsert(grpc_event, source)
}
}

View File

@@ -0,0 +1,150 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
ResolvedHttpRequestSettings, ResolvedSetting,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> ClientDb<'a> {
pub fn get_grpc_request(&self, id: &str) -> Result<GrpcRequest> {
self.find_one(GrpcRequestIden::Id, id)
}
pub fn list_grpc_requests(&self, workspace_id: &str) -> Result<Vec<GrpcRequest>> {
self.find_many(GrpcRequestIden::WorkspaceId, workspace_id, None)
}
pub fn list_grpc_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<GrpcRequest>> {
let mut children = Vec::new();
for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_grpc_requests_for_folder_recursive(&folder.id)?);
}
for request in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, folder_id, None)? {
children.push(request);
}
Ok(children)
}
pub fn resolve_auth_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = grpc_request.authentication_type.clone() {
return Ok((Some(at), grpc_request.authentication.clone(), grpc_request.id.clone()));
}
if let Some(folder_id) = grpc_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_metadata_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut metadata = Vec::new();
if let Some(folder_id) = grpc_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
metadata.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace);
metadata.append(&mut workspace_metadata);
}
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
}
pub fn resolve_settings_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = grpc_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&folder)?
} else {
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if grpc_request.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
grpc_request.setting_validate_certificates.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.validate_certificates
},
request_message_size: if grpc_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
grpc_request.setting_request_message_size.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.request_message_size
},
..parent
})
}
}
impl<'a> WriteDb<'a> {
pub fn delete_grpc_request(
&self,
m: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_grpc_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let request = self.get_grpc_request(id)?;
self.delete_grpc_request(&request, source)
}
pub fn duplicate_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let mut request = grpc_request.clone();
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
let sibling_names = self
.list_grpc_requests(&request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
request.name = conflict_free_name(&request.name, &sibling_names);
self.upsert(&request, source)
}
pub fn upsert_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.upsert(grpc_request, source)
}
}

View File

@@ -0,0 +1,312 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
ResolvedHttpRequestSettings, ResolvedSetting,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> ClientDb<'a> {
pub fn get_http_request(&self, id: &str) -> Result<HttpRequest> {
self.find_one(HttpRequestIden::Id, id)
}
pub fn list_http_requests(&self, workspace_id: &str) -> Result<Vec<HttpRequest>> {
self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)
}
pub fn resolve_auth_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = http_request.authentication_type.clone() {
return Ok((Some(at), http_request.authentication.clone(), http_request.id.clone()));
}
if let Some(folder_id) = http_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&http_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
if let Some(folder_id) = http_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&http_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
Ok(merge_headers(headers, http_request.headers.clone()))
}
pub fn resolve_settings_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = http_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&folder)?
} else {
let workspace = self.get_workspace(&http_request.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if http_request.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
http_request.setting_validate_certificates.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.validate_certificates
},
follow_redirects: if http_request.setting_follow_redirects.enabled {
ResolvedSetting::from_model(
http_request.setting_follow_redirects.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.follow_redirects
},
request_timeout: if http_request.setting_request_timeout.enabled {
ResolvedSetting::from_model(
http_request.setting_request_timeout.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.request_timeout
},
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model(
http_request.setting_send_cookies.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.send_cookies
},
store_cookies: if http_request.setting_store_cookies.enabled {
ResolvedSetting::from_model(
http_request.setting_store_cookies.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.store_cookies
},
http_version: if http_request.setting_http_version.enabled {
ResolvedSetting::from_model(
http_request.setting_http_version.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.http_version
},
})
}
pub fn list_http_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<HttpRequest>> {
let mut children = Vec::new();
for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);
}
for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {
children.push(m);
}
Ok(children)
}
}
impl<'a> WriteDb<'a> {
pub fn delete_http_request(
&self,
m: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_http_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<HttpRequest> {
let http_request = self.get_http_request(id)?;
self.delete_http_request(&http_request, source)
}
pub fn duplicate_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
let mut http_request = http_request.clone();
http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001;
let sibling_names = self
.list_http_requests(&http_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == http_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
self.upsert(&http_request, source)
}
pub fn upsert_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.upsert(http_request, source)
}
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::{
Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace,
};
use crate::util::UpdateSource;
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let workspace = query_manager
.with_tx(|tx| tx.upsert_workspace(&Workspace::default(), &UpdateSource::Background))
.expect("Failed to create workspace");
let db = query_manager.connect();
let request = HttpRequest {
workspace_id: workspace.id,
headers: vec![
HttpRequestHeader {
name: "Cookie".to_string(),
value: "required=1".to_string(),
..Default::default()
},
HttpRequestHeader {
enabled: false,
name: "Cookie".to_string(),
value: "optional=1".to_string(),
..Default::default()
},
],
..Default::default()
};
let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve");
let cookies = resolved
.iter()
.filter(|header| header.name.eq_ignore_ascii_case("cookie"))
.collect::<Vec<_>>();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].value, "required=1");
assert_eq!(cookies[1].value, "optional=1");
assert!(!cookies[1].enabled);
}
#[test]
fn http_version_resolves_through_the_inheritance_chain() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let source = &UpdateSource::Background;
let (folder, request) = query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace {
name: "Test".to_string(),
setting_http_version: HttpVersion::Http2,
..Default::default()
},
source,
)?;
let folder = db.upsert_folder(
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)?;
let request = db.upsert_http_request(
&HttpRequest {
workspace_id: workspace.id.clone(),
folder_id: Some(folder.id.clone()),
..Default::default()
},
source,
)?;
Ok::<_, crate::error::Error>((folder, request))
})
.expect("Failed to seed");
// No overrides, so the workspace base value applies
let resolved = query_manager
.connect()
.resolve_settings_for_http_request(&request)
.expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
assert_eq!(resolved.http_version.source_model, "workspace");
// A folder override beats the workspace base
query_manager
.with_tx(|db| {
db.upsert_folder(
&Folder {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Http1,
},
..folder
},
source,
)
})
.expect("Failed to update folder");
let resolved = query_manager
.connect()
.resolve_settings_for_http_request(&request)
.expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
assert_eq!(resolved.http_version.source_model, "folder");
// A request override beats them both
let request = query_manager
.with_tx(|db| {
db.upsert_http_request(
&HttpRequest {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Auto,
},
..request
},
source,
)
})
.expect("Failed to update request");
let resolved = query_manager
.connect()
.resolve_settings_for_http_request(&request)
.expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
assert_eq!(resolved.http_version.source_model, "http_request");
}
}

View File

@@ -0,0 +1,20 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_http_response_event(
&self,
http_response_event: &HttpResponseEvent,
source: &UpdateSource,
) -> Result<HttpResponseEvent> {
self.upsert(http_response_event, source)
}
}

View File

@@ -0,0 +1,300 @@
use crate::blob_manager::BlobManager;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::{debug, error};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::fs;
impl<'a> ClientDb<'a> {
pub fn get_http_response(&self, id: &str) -> Result<HttpResponse> {
self.find_one(HttpResponseIden::Id, id)
}
pub fn list_http_responses_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<HttpResponse>> {
self.find_many(HttpResponseIden::RequestId, request_id, limit)
}
pub fn list_http_responses(
&self,
workspace_id: &str,
limit: Option<u64>,
) -> Result<Vec<HttpResponse>> {
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
}
/// Delete blob-stored response bodies whose owning HTTP response row no
/// longer exists. Blob ids are keyed by the response that owns them —
/// "{response_id}" for a response body, "{response_id}.request" for the
/// request that produced it — so ownership is the id's first segment.
///
/// The blob half of [`Self::delete_orphaned_response_bodies`], on its own
/// for hosts with no filesystem to hold body files. See `crate::hooks`.
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
let orphaned = blobs
.connect()
.list_body_ids()?
.into_iter()
.filter(|body_id| {
let response_id = body_id.split('.').next().unwrap_or_default();
self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_none()
})
.collect::<Vec<_>>();
blobs.with_tx(|b| {
for body_id in &orphaned {
b.delete_chunks(body_id)?;
}
Ok::<_, crate::error::Error>(())
})?;
Ok(orphaned.len())
}
/// Delete response body data (blob chunks and body files) whose owning HTTP
/// response row no longer exists. Cascaded deletes (request, folder,
/// workspace) historically never cleaned the blob DB or the responses
/// directory, so orphans accumulate; this runs in the background at startup.
///
/// Safe against in-flight sends: the response row is created before its
/// body file or chunks are written.
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_bodies(
&self,
blobs: &BlobManager,
responses_dir: &std::path::Path,
) -> Result<usize> {
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
// Body files are stored as {responses_dir}/{response_id}
if let Ok(entries) = fs::read_dir(responses_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
if fs::remove_file(&path).is_ok() {
deleted += 1;
}
}
}
Ok(deleted)
}
}
impl<'a> WriteDb<'a> {
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<usize> {
let responses = self.list_http_responses_for_request(request_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(count)
}
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<usize> {
let responses =
self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, workspace_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(count)
}
pub fn delete_http_response(
&self,
http_response: &HttpResponse,
source: &UpdateSource,
blob_manager: &BlobManager,
) -> Result<HttpResponse> {
// Delete the body file if it exists
if let Some(p) = http_response.body_path.clone() {
if let Err(e) = fs::remove_file(p) {
error!("Failed to delete body file: {}", e);
};
}
// Delete request body blobs (pattern: {response_id}.request)
let body_id = format!("{}.request", http_response.id);
if let Err(e) = blob_manager.with_tx(|b| b.delete_chunks(&body_id)) {
error!("Failed to delete request body blobs: {}", e);
}
Ok(self.delete(http_response, source)?)
}
pub fn upsert_http_response(
&self,
http_response: &HttpResponse,
source: &UpdateSource,
blob_manager: &BlobManager,
) -> Result<HttpResponse> {
let responses = self.list_http_responses_for_request(&http_response.request_id, None)?;
for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old HTTP response {}", http_response.id);
self.delete_http_response(&m, source, blob_manager)?;
}
self.upsert(http_response, source)
}
pub fn cancel_pending_http_responses(&self) -> Result<()> {
let closed = serde_json::to_value(&HttpResponseState::Closed)?;
let (sql, params) = Query::update()
.table(HttpResponseIden::Table)
.values([(HttpResponseIden::State, closed.as_str().into())])
.cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
pub fn update_http_response_if_id(
&self,
response: &HttpResponse,
source: &UpdateSource,
) -> Result<HttpResponse> {
if response.id.is_empty() { Ok(response.clone()) } else { self.upsert(response, source) }
}
}
#[cfg(test)]
mod tests {
use crate::blob_manager::{BlobManager, BodyChunk};
use crate::error::Error;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
use crate::query_manager::QueryManager;
use crate::util::UpdateSource;
/// A workspace, a request, and one response that still exists.
fn seed_live_response(
query_manager: &QueryManager,
blob_manager: &BlobManager,
) -> HttpResponse {
let source = &UpdateSource::Background;
query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace { name: "GC Test".to_string(), ..Default::default() },
source,
)?;
let request = db.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)?;
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
blob_manager,
)
})
.expect("Failed to seed response")
}
fn insert_bodies(blob_manager: &BlobManager, body_ids: &[&str]) {
blob_manager
.with_tx(|b| {
for id in body_ids {
b.insert_chunk(&BodyChunk::new(*id, 0, b"data".to_vec()))?;
}
Ok::<_, Error>(())
})
.expect("Failed to insert chunks");
}
/// What a browser host runs: no filesystem, so bodies exist only as blob
/// chunks, under both id shapes the blob DB uses.
#[test]
fn deletes_orphaned_response_body_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let live = seed_live_response(&query_manager, &blob_manager);
let live_request_body_id = format!("{}.request", live.id);
insert_bodies(
&blob_manager,
&[
&live.id,
&live_request_body_id,
"rs_gone",
"rs_gone.request",
],
);
let deleted = query_manager
.connect()
.delete_orphaned_response_body_blobs(&blob_manager)
.expect("Failed to GC response body blobs");
assert_eq!(deleted, 2);
let blob_ctx = blob_manager.connect();
assert!(blob_ctx.body_exists(&live.id).unwrap());
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
}
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let live = seed_live_response(&query_manager, &blob_manager);
let live_body_id = format!("{}.request", live.id);
insert_bodies(&blob_manager, &[&live_body_id, "rs_gone.request"]);
let dir = std::env::temp_dir().join(format!("yaak-blob-gc-test-{}", live.id));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(&live.id), b"live").unwrap();
std::fs::write(dir.join("rs_gone"), b"dead").unwrap();
let deleted = query_manager
.connect()
.delete_orphaned_response_bodies(&blob_manager, &dir)
.expect("Failed to GC response bodies");
assert_eq!(deleted, 2);
// Live data survives, orphans are gone
let blob_ctx = blob_manager.connect();
assert!(blob_ctx.body_exists(&live_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
assert!(dir.join(&live.id).exists());
assert!(!dir.join("rs_gone").exists());
std::fs::remove_dir_all(&dir).ok();
}
}

View File

@@ -0,0 +1,96 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
use sea_query::ExprTrait;
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn list_import_source_resources(
&self,
import_source_id: &str,
) -> Result<Vec<ImportSourceResource>> {
let (sql, params) = Query::select()
.from(ImportSourceResourceIden::Table)
.column(Asterisk)
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
Ok(items.filter_map(|v| v.ok()).collect())
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_import_source_resource(
&self,
resource: &ImportSourceResource,
) -> Result<ImportSourceResource> {
let (sql, params) = Query::insert()
.into_table(ImportSourceResourceIden::Table)
.columns([
ImportSourceResourceIden::CreatedAt,
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.values_panic([
CurrentTimestamp.into(),
CurrentTimestamp.into(),
resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(),
resource.model_type.as_str().into(),
resource.model_id.clone().into(),
resource.content_hash.clone().into(),
])
.on_conflict(
OnConflict::columns([
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
])
.update_columns([
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.to_owned(),
)
.returning_all()
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?;
Ok(m)
}
pub fn delete_import_source_resource(
&self,
import_source_id: &str,
source_key: &str,
) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
}

View File

@@ -0,0 +1,47 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{ImportSource, ImportSourceIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_import_source(&self, id: &str) -> Result<ImportSource> {
self.find_one(ImportSourceIden::Id, id)
}
pub fn list_import_sources(&self, workspace_id: &str) -> Result<Vec<ImportSource>> {
self.find_many(ImportSourceIden::WorkspaceId, workspace_id, None)
}
pub fn list_import_sources_by_origin(&self, origin: &str) -> Result<Vec<ImportSource>> {
self.find_many(ImportSourceIden::Origin, origin, None)
}
pub fn find_import_source(
&self,
workspace_id: &str,
importer: &str,
origin: &str,
) -> Result<Option<ImportSource>> {
let sources = self.list_import_sources(workspace_id)?;
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.upsert(import_source, source)
}
pub fn delete_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.delete_import_source_resources(&import_source.id)?;
self.delete(import_source, source)
}
}

View File

@@ -0,0 +1,179 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
use crate::util::UpdateSource;
use chrono::NaiveDateTime;
use log::error;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn list_key_values(&self) -> Result<Vec<KeyValue>> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
// Temporary clause to prevent bug when reverting to the previous version, before the
// ID column was added. A previous version will not know about ID and will create
// key/value entries that don't have one. This clause ensures they are not queried
// TODO: Add migration to delete key/values with NULL IDs later on, then remove this
.cond_where(Expr::col(KeyValueIden::Id).is_not_null())
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn get_key_value_str(&self, namespace: &str, key: &str, default: &str) -> String {
match self.get_key_value_raw(namespace, key) {
None => default.to_string(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse string key value: {}", e);
default.to_string()
}
}
}
}
}
pub fn get_key_value_dte(
&self,
namespace: &str,
key: &str,
default: NaiveDateTime,
) -> NaiveDateTime {
match self.get_key_value_raw(namespace, key) {
None => default,
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse date key value: {}", e);
default
}
}
}
}
}
pub fn get_key_value_int(&self, namespace: &str, key: &str, default: i32) -> i32 {
match self.get_key_value_raw(namespace, key) {
None => default.clone(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse int key value: {}", e);
default.clone()
}
}
}
}
}
pub fn get_key_value_raw(&self, namespace: &str, key: &str) -> Option<KeyValue> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(KeyValueIden::Namespace).eq(namespace))
.add(Expr::col(KeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()
}
}
impl<'a> WriteDb<'a> {
pub fn set_key_value_dte(
&self,
namespace: &str,
key: &str,
value: NaiveDateTime,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_str(
&self,
namespace: &str,
key: &str,
value: &str,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_int(
&self,
namespace: &str,
key: &str,
value: i32,
source: &UpdateSource,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value).unwrap();
self.set_key_value_raw(namespace, key, &encoded, source)
}
pub fn set_key_value_raw(
&self,
namespace: &str,
key: &str,
value: &str,
source: &UpdateSource,
) -> (KeyValue, bool) {
match self.get_key_value_raw(namespace, key) {
None => (
self.upsert_key_value(
&KeyValue {
namespace: namespace.to_string(),
key: key.to_string(),
value: value.to_string(),
..Default::default()
},
source,
)
.expect("Failed to create key value"),
true,
),
Some(kv) => (
self.upsert_key_value(&KeyValue { value: value.to_string(), ..kv }, source)
.expect("Failed to update key value"),
false,
),
}
}
pub fn upsert_key_value(
&self,
key_value: &KeyValue,
source: &UpdateSource,
) -> Result<KeyValue> {
self.upsert(key_value, source)
}
pub fn delete_key_value(
&self,
namespace: &str,
key: &str,
source: &UpdateSource,
) -> Result<()> {
let kv = match self.get_key_value_raw(namespace, key) {
None => return Ok(()),
Some(m) => m,
};
self.delete(&kv, source)?;
Ok(())
}
}

View File

@@ -0,0 +1,88 @@
pub mod any_request;
mod batch;
mod cookie_jars;
mod duplicate_name;
mod environments;
mod folders;
mod graphql_introspections;
mod grpc_connections;
mod grpc_events;
mod grpc_requests;
mod http_requests;
mod http_response_events;
mod http_responses;
mod import_source_resources;
mod import_sources;
mod key_values;
mod model_changes;
mod plugin_key_values;
mod plugins;
mod settings;
mod sync_states;
mod websocket_connections;
mod websocket_events;
mod websocket_requests;
mod workspace_metas;
pub mod workspaces;
pub(crate) use duplicate_name::conflict_free_name;
pub use model_changes::PersistedModelChange;
const MAX_HISTORY_ITEMS: usize = 20;
use crate::models::HttpRequestHeader;
use std::collections::HashSet;
/// Merge a more-specific header layer over its parent. Names in the child replace
/// inherited values case-insensitively, while duplicates declared together in
/// either layer remain independent entries.
pub(crate) fn merge_headers(
mut parent: Vec<HttpRequestHeader>,
child: Vec<HttpRequestHeader>,
) -> Vec<HttpRequestHeader> {
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
parent.extend(child);
parent
}
#[cfg(test)]
mod tests {
use super::merge_headers;
use crate::models::HttpRequestHeader;
fn header(name: &str, value: &str) -> HttpRequestHeader {
HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() }
}
#[test]
fn preserves_duplicate_headers_declared_in_one_layer() {
let merged = merge_headers(
vec![header("Cookie", "inherited=1")],
vec![
header("Cookie", "required=1"),
header("cookie", "optional=1"),
],
);
assert_eq!(
merged.iter().map(|header| header.value.as_str()).collect::<Vec<_>>(),
vec!["required=1", "optional=1"],
);
}
#[test]
fn child_names_override_parent_names_without_affecting_other_headers() {
let merged = merge_headers(
vec![header("Accept", "*/*"), header("X-Parent", "kept")],
vec![header("accept", "application/json")],
);
assert_eq!(
merged
.iter()
.map(|header| (header.name.as_str(), header.value.as_str()))
.collect::<Vec<_>>(),
vec![("X-Parent", "kept"), ("accept", "application/json")],
);
}
}

View File

@@ -0,0 +1,330 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::util::ModelPayload;
use rusqlite::params;
use rusqlite::types::Type;
#[derive(Debug, Clone)]
pub struct PersistedModelChange {
pub id: i64,
pub created_at: String,
pub payload: ModelPayload,
}
impl<'a> ClientDb<'a> {
pub fn list_model_changes_after(
&self,
after_id: i64,
limit: usize,
) -> Result<Vec<PersistedModelChange>> {
let mut stmt = self.conn().prepare(
r#"
SELECT id, created_at, payload
FROM model_changes
WHERE id > ?1
ORDER BY id ASC
LIMIT ?2
"#,
)?;
let items = stmt.query_map(params![after_id, limit as i64], |row| {
let id: i64 = row.get(0)?;
let created_at: String = row.get(1)?;
let payload_raw: String = row.get(2)?;
let payload = serde_json::from_str::<ModelPayload>(&payload_raw).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e))
})?;
Ok(PersistedModelChange { id, created_at, payload })
})?;
Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)
}
pub fn list_model_changes_since(
&self,
since_created_at: &str,
since_id: i64,
limit: usize,
) -> Result<Vec<PersistedModelChange>> {
let mut stmt = self.conn().prepare(
r#"
SELECT id, created_at, payload
FROM model_changes
WHERE created_at > ?1
OR (created_at = ?1 AND id > ?2)
ORDER BY created_at ASC, id ASC
LIMIT ?3
"#,
)?;
let items = stmt.query_map(params![since_created_at, since_id, limit as i64], |row| {
let id: i64 = row.get(0)?;
let created_at: String = row.get(1)?;
let payload_raw: String = row.get(2)?;
let payload = serde_json::from_str::<ModelPayload>(&payload_raw).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e))
})?;
Ok(PersistedModelChange { id, created_at, payload })
})?;
Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)
}
}
impl<'a> WriteDb<'a> {
pub fn prune_model_changes_older_than_days(&self, days: i64) -> Result<usize> {
let offset = format!("-{days} days");
Ok(self.conn().resolve().execute(
r#"
DELETE FROM model_changes
WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1)
"#,
params![offset],
)?)
}
pub fn prune_model_changes_older_than_hours(&self, hours: i64) -> Result<usize> {
let offset = format!("-{hours} hours");
Ok(self.conn().resolve().execute(
r#"
DELETE FROM model_changes
WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1)
"#,
params![offset],
)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_in_memory;
use crate::models::Workspace;
use crate::util::{ModelChangeEvent, UpdateSource};
use serde_json::json;
/// Startup bootstraps rows of its own; these tests count only their own.
fn clear_changes(query_manager: &crate::query_manager::QueryManager) {
query_manager
.with_tx(|db| {
db.conn().resolve().execute("DELETE FROM model_changes", [])?;
Ok::<_, crate::error::Error>(())
})
.expect("Failed to clear model changes");
}
#[test]
fn records_model_changes_for_upsert_and_delete() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
clear_changes(&query_manager);
let workspace = query_manager
.with_tx(|db| {
db.upsert_workspace(
&Workspace {
name: "Changes Test".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Sync,
)
})
.expect("Failed to upsert workspace");
let db = query_manager.connect();
let created_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(created_changes.len(), 1);
assert_eq!(created_changes[0].payload.model.id(), workspace.id);
assert_eq!(created_changes[0].payload.model.model(), "workspace");
assert!(matches!(
created_changes[0].payload.change,
ModelChangeEvent::Upsert { created: true }
));
assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync));
drop(db);
query_manager
.with_tx(|db| {
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
})
.expect("Failed to delete workspace");
let db = query_manager.connect();
let all_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(all_changes.len(), 2);
assert!(matches!(all_changes[1].payload.change, ModelChangeEvent::Delete));
assert!(all_changes[1].id > all_changes[0].id);
let changes_after_first = db
.list_model_changes_after(all_changes[0].id, 10)
.expect("Failed to list changes after cursor");
assert_eq!(changes_after_first.len(), 1);
assert!(matches!(changes_after_first[0].payload.change, ModelChangeEvent::Delete));
}
#[test]
fn prunes_old_model_changes() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
clear_changes(&query_manager);
query_manager
.with_tx(|db| {
db.upsert_workspace(
&Workspace {
name: "Prune Test".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Sync,
)
})
.expect("Failed to upsert workspace");
let db = query_manager.connect();
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(changes.len(), 1);
db.conn()
.resolve()
.execute(
"UPDATE model_changes SET created_at = '2000-01-01 00:00:00.000' WHERE id = ?1",
params![changes[0].id],
)
.expect("Failed to age model change row");
drop(db);
let pruned = query_manager
.with_tx(|db| db.prune_model_changes_older_than_days(30))
.expect("Failed to prune model changes");
assert_eq!(pruned, 1);
assert!(
query_manager
.connect()
.list_model_changes_after(0, 10)
.expect("Failed to list changes")
.is_empty()
);
}
#[test]
fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
clear_changes(&query_manager);
query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace {
name: "Cursor Test".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Sync,
)?;
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
})
.expect("Failed to seed changes");
let db = query_manager.connect();
let all = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(all.len(), 2);
let fixed_ts = "2026-02-16 00:00:00.000";
db.conn()
.resolve()
.execute("UPDATE model_changes SET created_at = ?1", params![fixed_ts])
.expect("Failed to normalize timestamps");
let after_first =
db.list_model_changes_since(fixed_ts, all[0].id, 10).expect("Failed to query cursor");
assert_eq!(after_first.len(), 1);
assert_eq!(after_first[0].id, all[1].id);
}
#[test]
fn prunes_old_model_changes_by_hours() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
clear_changes(&query_manager);
query_manager
.with_tx(|db| {
db.upsert_workspace(
&Workspace {
name: "Prune Hour Test".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Sync,
)
})
.expect("Failed to upsert workspace");
let db = query_manager.connect();
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(changes.len(), 1);
db.conn()
.resolve()
.execute(
"UPDATE model_changes SET created_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', '-2 hours') WHERE id = ?1",
params![changes[0].id],
)
.expect("Failed to age model change row");
drop(db);
let pruned = query_manager
.with_tx(|db| db.prune_model_changes_older_than_hours(1))
.expect("Failed to prune model changes");
assert_eq!(pruned, 1);
}
#[test]
fn list_model_changes_deserializes_http_response_event_payload() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
clear_changes(&query_manager);
let db = query_manager.connect();
let payload = json!({
"model": {
"model": "http_response_event",
"id": "re_test",
"createdAt": "2026-02-16T21:01:34.809162",
"updatedAt": "2026-02-16T21:01:34.809163",
"workspaceId": "wk_test",
"responseId": "rs_test",
"event": {
"type": "info",
"message": "hello"
}
},
"updateSource": { "type": "sync" },
"change": { "type": "upsert", "created": false }
});
db.conn()
.resolve()
.execute(
r#"
INSERT INTO model_changes (model, model_id, change, update_source, payload)
VALUES (?1, ?2, ?3, ?4, ?5)
"#,
params![
"http_response_event",
"re_test",
r#"{"type":"upsert","created":false}"#,
r#"{"type":"sync"}"#,
payload.to_string(),
],
)
.expect("Failed to insert model change row");
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].payload.model.model(), "http_response_event");
assert_eq!(changes[0].payload.model.id(), "re_test");
}
}

View File

@@ -0,0 +1,85 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{PluginKeyValue, PluginKeyValueIden};
use sea_query::ExprTrait;
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn get_plugin_key_value(&self, plugin_name: &str, key: &str) -> Option<PluginKeyValue> {
let (sql, params) = Query::select()
.from(PluginKeyValueIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(PluginKeyValueIden::PluginName).eq(plugin_name))
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn()
.resolve()
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
.ok()
}
}
impl<'a> WriteDb<'a> {
pub fn set_plugin_key_value(
&self,
plugin_name: &str,
key: &str,
value: &str,
) -> (PluginKeyValue, bool) {
let existing = self.get_plugin_key_value(plugin_name, key);
let (sql, params) = Query::insert()
.into_table(PluginKeyValueIden::Table)
.columns([
PluginKeyValueIden::CreatedAt,
PluginKeyValueIden::UpdatedAt,
PluginKeyValueIden::PluginName,
PluginKeyValueIden::Key,
PluginKeyValueIden::Value,
])
.values_panic([
CurrentTimestamp.into(),
CurrentTimestamp.into(),
plugin_name.into(),
key.into(),
value.into(),
])
.on_conflict(
OnConflict::new()
.update_columns([PluginKeyValueIden::UpdatedAt, PluginKeyValueIden::Value])
.to_owned(),
)
.returning_all()
.build_rusqlite(SqliteQueryBuilder);
let mut stmt =
self.conn().prepare(sql.as_str()).expect("Failed to prepare PluginKeyValue upsert");
let m: PluginKeyValue = stmt
.query_row(&*params.as_params(), |row| row.try_into())
.expect("Failed to upsert KeyValue");
(m, existing.is_none())
}
pub fn delete_plugin_key_value(&self, namespace: &str, key: &str) -> Result<bool> {
if let None = self.get_plugin_key_value(namespace, key) {
return Ok(false);
};
let (sql, params) = Query::delete()
.from_table(PluginKeyValueIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(PluginKeyValueIden::PluginName).eq(namespace))
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(true)
}
}

View File

@@ -0,0 +1,37 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{Plugin, PluginIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_plugin(&self, id: &str) -> Result<Plugin> {
self.find_one(PluginIden::Id, id)
}
pub fn get_plugin_by_directory(&self, directory: &str) -> Option<Plugin> {
self.find_optional(PluginIden::Directory, directory)
}
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
self.find_all()
}
}
impl<'a> WriteDb<'a> {
pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
self.delete(plugin, source)
}
pub fn delete_plugin_by_id(&self, id: &str, source: &UpdateSource) -> Result<Plugin> {
let plugin = self.get_plugin(id)?;
self.delete_plugin(&plugin, source)
}
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
let mut plugin_to_upsert = plugin.clone();
if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) {
plugin_to_upsert.id = existing.id;
}
self.upsert(&plugin_to_upsert, source)
}
}

View File

@@ -0,0 +1,61 @@
use std::collections::HashMap;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{EditorKeymap, Settings, SettingsIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
/// The settings row, or the defaults if it has not been written yet.
/// [`WriteDb::ensure_settings`] persists it at startup.
pub fn get_settings(&self) -> Settings {
self.find_optional::<Settings>(SettingsIden::Id, "default").unwrap_or_else(default_settings)
}
}
impl<'a> WriteDb<'a> {
/// Create the settings row if it does not exist.
pub fn ensure_settings(&self) -> Result<Settings> {
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, "default") {
return Ok(s);
}
self.upsert(&default_settings(), &UpdateSource::Background)
}
pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result<Settings> {
self.upsert(settings, source)
}
}
fn default_settings() -> Settings {
Settings {
model: "settings".to_string(),
id: "default".to_string(),
created_at: Default::default(),
updated_at: Default::default(),
appearance: "system".to_string(),
client_certificates: Vec::new(),
editor_font_size: 12,
editor_font: None,
editor_keymap: EditorKeymap::Default,
editor_soft_wrap: true,
interface_font_size: 14,
interface_scale: 1.0,
interface_font: None,
hide_window_controls: false,
use_native_titlebar: false,
open_workspace_new_window: None,
proxy: None,
theme_dark: "yaak-dark".to_string(),
theme_light: "yaak-light".to_string(),
update_channel: "stable".to_string(),
autoupdate: true,
colored_methods: false,
hide_license_badge: false,
prompt_feedback: true,
auto_download_updates: true,
check_notifications: true,
hotkeys: HashMap::new(),
}
}

View File

@@ -0,0 +1,48 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
use crate::util::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::path::Path;
impl<'a> ClientDb<'a> {
pub fn get_sync_state(&self, id: &str) -> Result<SyncState> {
self.find_one(SyncStateIden::Id, id)
}
pub fn list_sync_states_for_workspace(
&self,
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<SyncState>> {
let (sql, params) = Query::select()
.from(SyncStateIden::Table)
.column(Asterisk)
.cond_where(
Cond::all()
.add(Expr::col(SyncStateIden::WorkspaceId).eq(workspace_id))
.add(Expr::col(SyncStateIden::SyncDir).eq(sync_dir.to_string_lossy())),
)
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
self.upsert(sync_state, &UpdateSource::Sync)
}
pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
self.delete(sync_state, &UpdateSource::Sync)
}
pub fn delete_sync_state_by_id(&self, id: &str) -> Result<SyncState> {
let sync_state = self.get_sync_state(id)?;
self.delete_sync_state(&sync_state)
}
}

View File

@@ -0,0 +1,100 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> ClientDb<'a> {
pub fn get_websocket_connection(&self, id: &str) -> Result<WebsocketConnection> {
self.find_one(WebsocketConnectionIden::Id, id)
}
pub fn list_websocket_connections(
&self,
workspace_id: &str,
) -> Result<Vec<WebsocketConnection>> {
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
}
pub fn list_websocket_connections_for_request(
&self,
request_id: &str,
) -> Result<Vec<WebsocketConnection>> {
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn delete_all_websocket_connections_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_websocket_connections_for_request(request_id)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_all_websocket_connections_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
let responses = self.list_websocket_connections(workspace_id)?;
for m in responses {
self.delete(&m, source)?;
}
Ok(())
}
pub fn delete_websocket_connection(
&self,
websocket_connection: &WebsocketConnection,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
self.delete(websocket_connection, source)
}
pub fn delete_websocket_connection_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
let websocket_connection = self.get_websocket_connection(id)?;
self.delete_websocket_connection(&websocket_connection, source)
}
pub fn upsert_websocket_connection(
&self,
websocket_connection: &WebsocketConnection,
source: &UpdateSource,
) -> Result<WebsocketConnection> {
let connections =
self.list_websocket_connections_for_request(&websocket_connection.request_id)?;
for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {
debug!("Deleting old websocket connection {}", websocket_connection.id);
self.delete_websocket_connection(&m, source)?;
}
self.upsert(websocket_connection, source)
}
pub fn cancel_pending_websocket_connections(&self) -> Result<()> {
let closed = serde_json::to_value(&WebsocketConnectionState::Closed)?;
let (sql, params) = Query::update()
.table(WebsocketConnectionIden::Table)
.values([(WebsocketConnectionIden::State, closed.as_str().into())])
.cond_where(Expr::col(WebsocketConnectionIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
}

View File

@@ -0,0 +1,24 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{WebsocketEvent, WebsocketEventIden};
use crate::util::UpdateSource;
impl<'a> ClientDb<'a> {
pub fn get_websocket_event(&self, id: &str) -> Result<WebsocketEvent> {
self.find_one(WebsocketEventIden::Id, id)
}
pub fn list_websocket_events(&self, connection_id: &str) -> Result<Vec<WebsocketEvent>> {
self.find_many(WebsocketEventIden::ConnectionId, connection_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_websocket_event(
&self,
websocket_event: &WebsocketEvent,
source: &UpdateSource,
) -> Result<WebsocketEvent> {
self.upsert(websocket_event, source)
}
}

View File

@@ -0,0 +1,172 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> ClientDb<'a> {
pub fn get_websocket_request(&self, id: &str) -> Result<WebsocketRequest> {
self.find_one(WebsocketRequestIden::Id, id)
}
pub fn list_websocket_requests(&self, workspace_id: &str) -> Result<Vec<WebsocketRequest>> {
self.find_many(WebsocketRequestIden::WorkspaceId, workspace_id, None)
}
pub fn list_websocket_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<WebsocketRequest>> {
let mut children = Vec::new();
for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_websocket_requests_for_folder_recursive(&folder.id)?);
}
for request in
self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, folder_id, None)?
{
children.push(request);
}
Ok(children)
}
pub fn resolve_auth_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {
if let Some(at) = websocket_request.authentication_type.clone() {
return Ok((
Some(at),
websocket_request.authentication.clone(),
websocket_request.id.clone(),
));
}
if let Some(folder_id) = websocket_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(&folder);
}
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
if let Some(folder_id) = websocket_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
Ok(merge_headers(headers, websocket_request.headers.clone()))
}
pub fn resolve_settings_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = websocket_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&folder)?
} else {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if websocket_request.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
websocket_request.setting_validate_certificates.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.validate_certificates
},
request_message_size: if websocket_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
websocket_request.setting_request_message_size.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if websocket_request.setting_send_cookies.enabled {
ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.send_cookies
},
store_cookies: if websocket_request.setting_store_cookies.enabled {
ResolvedSetting::from_model(
websocket_request.setting_store_cookies.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.store_cookies
},
..parent
})
}
}
impl<'a> WriteDb<'a> {
pub fn delete_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
self.delete(websocket_request, source)
}
pub fn delete_websocket_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
let request = self.get_websocket_request(id)?;
self.delete_websocket_request(&request, source)
}
pub fn duplicate_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
let mut websocket_request = websocket_request.clone();
websocket_request.id = "".to_string();
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
let sibling_names = self
.list_websocket_requests(&websocket_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == websocket_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
self.upsert(&websocket_request, source)
}
pub fn upsert_websocket_request(
&self,
websocket_request: &WebsocketRequest,
source: &UpdateSource,
) -> Result<WebsocketRequest> {
self.upsert(websocket_request, source)
}
}

View File

@@ -0,0 +1,39 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
use crate::util::UpdateSource;
use log::info;
impl<'a> ClientDb<'a> {
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
self.find_optional(WorkspaceMetaIden::WorkspaceId, workspace_id)
}
pub fn list_workspace_metas(&self, workspace_id: &str) -> Result<Vec<WorkspaceMeta>> {
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn ensure_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta = self.get_workspace_meta(workspace_id);
if let Some(workspace_meta) = workspace_meta {
return Ok(workspace_meta);
}
let workspace_meta =
WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };
info!("Creating WorkspaceMeta for {workspace_id}");
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
}
pub fn upsert_workspace_meta(
&self,
workspace_meta: &WorkspaceMeta,
source: &UpdateSource,
) -> Result<WorkspaceMeta> {
self.upsert(workspace_meta, source)
}
}

View File

@@ -0,0 +1,218 @@
use super::merge_headers;
use crate::blob_manager::BlobManager;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
WorkspaceMetaIden,
};
use crate::util::UpdateSource;
use log::warn;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> ClientDb<'a> {
pub fn get_workspace(&self, id: &str) -> Result<Workspace> {
self.find_one(WorkspaceIden::Id, id)
}
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
self.find_all()
}
pub fn resolve_auth_for_workspace(
&self,
workspace: &Workspace,
) -> (Option<String>, BTreeMap<String, Value>, String) {
(
workspace.authentication_type.clone(),
workspace.authentication.clone(),
workspace.id.clone(),
)
}
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
merge_headers(default_headers(), workspace.headers.clone())
}
pub fn resolve_settings_for_workspace(
&self,
workspace: &Workspace,
) -> ResolvedHttpRequestSettings {
ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::from_model(
workspace.setting_validate_certificates,
AnyModel::Workspace(workspace.clone()),
),
follow_redirects: ResolvedSetting::from_model(
workspace.setting_follow_redirects,
AnyModel::Workspace(workspace.clone()),
),
request_timeout: ResolvedSetting::from_model(
workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()),
),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()),
),
store_cookies: ResolvedSetting::from_model(
workspace.setting_store_cookies,
AnyModel::Workspace(workspace.clone()),
),
http_version: ResolvedSetting::from_model(
workspace.setting_http_version,
AnyModel::Workspace(workspace.clone()),
),
}
}
}
impl<'a> WriteDb<'a> {
/// Delete a workspace and everything in it.
///
/// Children are bulk-deleted with one statement per table and are NOT
/// individually recorded in model_changes or emitted as events — the single
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
/// This keeps huge workspaces (thousands of requests) fast and avoids
/// flooding event consumers.
pub fn delete_workspace(
&self,
workspace: &Workspace,
source: &UpdateSource,
blobs: &BlobManager,
) -> Result<Workspace> {
let wid = workspace.id.as_str();
// Collect response cleanup targets before their rows disappear. The actual
// cleanup runs at the end: response bodies live on disk and in the blob DB,
// which don't participate in this transaction, so removing them must wait
// until every statement that could fail (and roll back the rows) is done.
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
self.delete_many_untracked::<HttpResponseEvent>(HttpResponseEventIden::WorkspaceId, wid)?;
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WebsocketConnection>(
WebsocketConnectionIden::WorkspaceId,
wid,
)?;
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GraphQlIntrospection>(
GraphQlIntrospectionIden::WorkspaceId,
wid,
)?;
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
for import_source in self.list_import_sources(wid)? {
self.delete_import_source_resources(&import_source.id)?;
}
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
let deleted = self.delete(workspace, source)?;
// Best-effort cleanup of response bodies (disk files and blob chunks).
// Failures only orphan unreferenced data, and are logged.
for m in responses {
if let Some(p) = m.body_path {
if let Err(e) = std::fs::remove_file(&p) {
warn!("Failed to delete response body file {p:?}: {e}");
}
}
let pattern = format!("{}.%", m.id);
if let Err(e) = blobs.with_tx(|b| b.delete_chunks_like(&pattern)) {
warn!("Failed to delete blobs for response {}: {e}", m.id);
}
}
Ok(deleted)
}
pub fn delete_workspace_by_id(
&self,
id: &str,
source: &UpdateSource,
blobs: &BlobManager,
) -> Result<Workspace> {
let workspace = self.get_workspace(id)?;
self.delete_workspace(&workspace, source, blobs)
}
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
self.upsert(w, source)
}
}
/// Global default headers that are always sent with requests unless overridden.
/// These are prepended to the inheritance chain so workspace/folder/request headers
/// can override or disable them.
pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![
HttpRequestHeader {
name: "User-Agent".to_string(),
value: "yaak".to_string(),
..Default::default()
},
HttpRequestHeader {
name: "Accept".to_string(),
value: "*/*".to_string(),
..Default::default()
},
]
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::Workspace;
use crate::util::UpdateSource;
#[test]
fn fresh_install_has_no_workspaces() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let workspaces = query_manager.connect().list_workspaces().expect("Failed to list");
assert!(workspaces.is_empty());
}
#[test]
fn default_workspace_carries_real_defaults() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let created = query_manager
.with_tx(|tx| {
tx.upsert_workspace(
&Workspace { name: "Yaak".to_string(), ..Default::default() },
&UpdateSource::Background,
)
})
.expect("Failed to create workspace");
let workspace = query_manager.connect().get_workspace(&created.id).expect("get");
// A workspace built in Rust and never deserialized only gets these values
// if `Workspace::default()` carries them. Asserted through the DB round
// trip, since the column values are what the user lives with.
assert!(workspace.setting_send_cookies, "setting_send_cookies");
assert!(workspace.setting_store_cookies, "setting_store_cookies");
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
assert!(workspace.setting_validate_certificates, "setting_validate_certificates");
assert_eq!(
workspace.setting_request_message_size,
crate::models::DEFAULT_REQUEST_MESSAGE_SIZE
);
}
}

View File

@@ -0,0 +1,85 @@
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Error::GenericError;
use crate::util::ModelPayload;
use rusqlite::{Transaction, TransactionBehavior};
use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
/// Reads come from a pool; writes go through one connection.
///
/// SQLite in WAL mode lets many readers run alongside a single writer, and
/// never more than one writer. A second in-process writer can only wait, and
/// while it waits in the busy handler it sleeps, retries, and keeps its pool
/// slot. Enough of those and the pool is full of writers that are all asleep,
/// and every read in the app queues behind them. Giving writes exactly one
/// connection turns that into a plain queue: the next write starts the moment
/// the previous one commits, and it never takes a slot a read could use.
///
/// The pools are internally synchronized — don't wrap them in a Mutex. A Mutex
/// held across the blocking `get()` serializes every DB access behind the
/// slowest waiter.
#[derive(Debug, Clone)]
pub struct QueryManager {
readers: SqlitePool,
writer: SqlitePool,
events_tx: mpsc::Sender<ModelPayload>,
}
impl QueryManager {
/// `writer` must be a pool with a single connection; see [`crate::init_standalone`].
pub fn new(
readers: SqlitePool,
writer: SqlitePool,
events_tx: mpsc::Sender<ModelPayload>,
) -> Self {
QueryManager { readers, writer, events_tx }
}
/// A read handle from the reader pool.
pub fn connect(&self) -> ClientDb<'_> {
let conn = self.readers.get().expect("Failed to get a new DB connection from the pool");
ClientDb::new(DbContext::new(ConnectionOrTx::Connection(conn)))
}
/// Run `func` in a transaction on the writer connection.
///
/// Waits for any write in progress to commit first, and fails with a pool
/// error if that takes longer than the pool's timeout. Do not call this
/// from inside another `with_tx` closure: the inner call would wait for
/// the outer transaction, which is waiting on it.
///
/// Model events for the writes are sent once the transaction commits.
pub fn with_tx<T, E>(
&self,
func: impl FnOnce(&WriteDb) -> std::result::Result<T, E>,
) -> std::result::Result<T, E>
where
E: From<crate::error::Error>,
{
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.map_err(crate::error::Error::SqlError)?;
let db = WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)));
match func(&db) {
Ok(val) => {
let events = db.into_events();
tx.commit()
.map_err(|e| GenericError(format!("Failed to commit transaction {e:?}")))?;
for payload in events {
let _ = self.events_tx.send(payload);
}
Ok(val)
}
Err(e) => {
drop(db);
tx.rollback()
.map_err(|e| GenericError(format!("Failed to rollback transaction {e:?}")))?;
Err(e)
}
}
}
}

View File

@@ -0,0 +1,250 @@
//! Rendering requests against an environment chain.
//!
//! Lives here rather than beside the send engine so that the browser's wasm
//! host, which has the model layer but no sockets, renders exactly what the
//! desktop renders.
use crate::models::{
Environment, EnvironmentVariable, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use crate::path_placeholders::apply_path_placeholders;
use log::info;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
/// Render every template in an HTTP request against an environment chain.
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
let mut variables = HashMap::new();
for e in environment_chain.iter().rev() {
variables = add_variable_to_map(variables, &e.variables);
}
variables
}
fn add_variable_to_map(
m: HashMap<String, String>,
variables: &Vec<EnvironmentVariable>,
) -> HashMap<String, String> {
let mut map = m.clone();
for variable in variables {
if !variable.enabled {
continue;
}
let name = variable.name.as_str();
let value = variable.value.as_str();
map.insert(name.into(), value.into());
}
map
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}

View File

@@ -0,0 +1,328 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest,
Workspace, WorkspaceIden,
};
use chrono::{NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS;
use yaak_core::WorkspaceContext;
pub use yaak_database::{
ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ModelPayload {
pub model: AnyModel,
pub update_source: UpdateSource,
pub change: ModelChangeEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")]
pub enum UpdateSource {
Background,
Import,
Plugin,
Sync,
Window { label: String },
}
impl UpdateSource {
pub fn from_window_label(label: impl Into<String>) -> Self {
Self::Window { label: label.into() }
}
pub fn to_db(&self) -> yaak_database::UpdateSource {
match self {
UpdateSource::Background => yaak_database::UpdateSource::Background,
UpdateSource::Import => yaak_database::UpdateSource::Import,
UpdateSource::Plugin => yaak_database::UpdateSource::Plugin,
UpdateSource::Sync => yaak_database::UpdateSource::Sync,
UpdateSource::Window { label } => {
yaak_database::UpdateSource::Window { label: label.clone() }
}
}
}
}
impl From<yaak_database::UpdateSource> for UpdateSource {
fn from(source: yaak_database::UpdateSource) -> Self {
match source {
yaak_database::UpdateSource::Background => UpdateSource::Background,
yaak_database::UpdateSource::Import => UpdateSource::Import,
yaak_database::UpdateSource::Plugin => UpdateSource::Plugin,
yaak_database::UpdateSource::Sync => UpdateSource::Sync,
yaak_database::UpdateSource::Window { label } => UpdateSource::Window { label },
}
}
}
#[derive(Default, Debug, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WorkspaceExport {
pub yaak_version: String,
pub yaak_schema: i64,
pub timestamp: NaiveDateTime,
pub resources: BatchUpsertResult,
}
#[derive(Default, Debug, Deserialize, Serialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct BatchUpsertResult {
pub workspaces: Vec<Workspace>,
pub environments: Vec<Environment>,
pub folders: Vec<Folder>,
pub http_requests: Vec<HttpRequest>,
pub grpc_requests: Vec<GrpcRequest>,
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
ExistingWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
#[serde(default)]
pub level: ImportPlanWarningLevel,
}
/// Whether a plan's note is something to know or something to think twice about.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanWarningLevel {
#[default]
Info,
Warning,
}
impl ImportPlanWarning {
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Info }
}
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Warning }
}
}
/// Where an import's contents came from, used to link the committed workspace back to it.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportOrigin {
/// The absolute file path or URL the contents were read from.
pub origin: String,
pub label: String,
}
/// The model types an import plan can contain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportResourceType {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
}
impl ImportResourceType {
pub fn as_str(&self) -> &'static str {
match self {
ImportResourceType::Environment => "environment",
ImportResourceType::Folder => "folder",
ImportResourceType::GrpcRequest => "grpc_request",
ImportResourceType::HttpRequest => "http_request",
ImportResourceType::WebsocketRequest => "websocket_request",
ImportResourceType::Workspace => "workspace",
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"environment" => Some(ImportResourceType::Environment),
"folder" => Some(ImportResourceType::Folder),
"grpc_request" => Some(ImportResourceType::GrpcRequest),
"http_request" => Some(ImportResourceType::HttpRequest),
"websocket_request" => Some(ImportResourceType::WebsocketRequest),
"workspace" => Some(ImportResourceType::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanAction {
Create,
Update,
Delete,
Unchanged,
KeepLocal,
Conflict,
/// Present in the source but previously turned down; selecting it imports it again
Ignored,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoIgnoredFolder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportConflictResolution {
KeepMine,
TakeSource,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanItem {
pub action: ImportPlanAction,
pub model: ImportResourceType,
pub model_id: String,
pub name: String,
/// Planned parent folder ID for incoming resources; current parent for deletions.
#[ts(optional)]
pub parent_id: Option<String>,
pub selected: bool,
#[ts(optional)]
pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
/// Stable source key for every model in `resources`, keyed by its planned ID.
pub source_keys: BTreeMap<String, String>,
/// One entry per plannable resource; commit applies only the selected ones.
#[serde(default)]
pub items: Vec<ImportPlanItem>,
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
workspace_ids: Vec<&str>,
include_private_environments: bool,
) -> Result<WorkspaceExport> {
let mut data = WorkspaceExport {
yaak_version: yaak_version.to_string(),
yaak_schema: 4,
timestamp: Utc::now().naive_utc(),
resources: BatchUpsertResult {
workspaces: Vec::new(),
environments: Vec::new(),
folders: Vec::new(),
http_requests: Vec::new(),
grpc_requests: Vec::new(),
websocket_requests: Vec::new(),
},
};
for workspace_id in workspace_ids {
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
data.resources.environments.append(
&mut db
.list_environments(workspace_id)?
.into_iter()
.filter(|e| include_private_environments || e.public)
.collect(),
);
data.resources.folders.append(&mut db.list_folders(workspace_id)?);
data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);
data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);
data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);
}
Ok(data)
}
pub fn maybe_gen_id<M: UpsertModelInfo>(
ctx: &WorkspaceContext,
id: &str,
ids: &mut BTreeMap<String, String>,
) -> String {
if id == "CURRENT_WORKSPACE" {
if let Some(wid) = &ctx.workspace_id {
return wid.to_string();
}
}
if !id.starts_with("GENERATE_ID::") {
return id.to_string();
}
let unique_key = id.replace("GENERATE_ID", "");
if let Some(existing) = ids.get(unique_key.as_str()) {
existing.to_string()
} else {
let new_id = M::generate_id();
ids.insert(unique_key, new_id.clone());
new_id
}
}
pub fn maybe_gen_id_opt<M: UpsertModelInfo>(
ctx: &WorkspaceContext,
id: Option<String>,
ids: &mut BTreeMap<String, String>,
) -> Option<String> {
match id {
Some(id) => Some(maybe_gen_id::<M>(ctx, id.as_str(), ids)),
None => None,
}
}