use crate::error::Error::UnknownModel; use crate::error::Result; use chrono::NaiveDateTime; use log::{debug, warn}; use serde::{Deserialize, Deserializer, Serialize}; use serde_yaml::{Mapping, Value}; use sha1::{Digest, Sha1}; use std::fs; use std::path::Path; use ts_rs::TS; use yaak_models::models::{ AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, }; #[derive(Debug, Clone, PartialEq, Serialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_models.ts")] pub enum SyncModel { Workspace(Workspace), Environment(Environment), Folder(Folder), HttpRequest(HttpRequest), GrpcRequest(GrpcRequest), WebsocketRequest(WebsocketRequest), } impl<'de> Deserialize<'de> for SyncModel { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, { use serde_path_to_error as spte; let mut v = Value::deserialize(deserializer)?; let model = match v.get("model") { Some(Value::String(model)) => model.clone(), _ => "".to_string(), }; let model = model.as_str(); let obj = v .as_mapping_mut() .ok_or_else(|| serde::de::Error::custom("expected object for SyncModel"))?; // Dispatch to CHILD types (no recursion) match model { "workspace" => { let x: Workspace = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::Workspace(x)) } "environment" => { migrate_environment(obj); let x: Environment = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::Environment(x)) } "folder" => { let x: Folder = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::Folder(x)) } "http_request" => { let x: HttpRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::HttpRequest(x)) } "grpc_request" => { let x: GrpcRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::GrpcRequest(x)) } "websocket_request" => { let x: WebsocketRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?; Ok(SyncModel::WebsocketRequest(x)) } other => Err(serde::de::Error::unknown_variant( other, &[ "workspace", "environment", "folder", "http_request", "grpc_request", "websocket_request", ], )), } } } fn migrate_environment(obj: &mut Mapping) { match (obj.get("base"), obj.get("parentModel")) { (Some(Value::Bool(base)), None) => { debug!("Migrating legacy environment {:?}", obj.get("id")); if *base { obj.insert("parentModel".into(), "workspace".into()); } else { obj.insert("parentModel".into(), "environment".into()); } } _ => {} } } impl SyncModel { pub fn from_bytes(content: Vec, file_path: &Path) -> Result> { let mut hasher = Sha1::new(); hasher.update(&content); let checksum = hex::encode(hasher.finalize()); let content_str = String::from_utf8(content.clone()).unwrap_or_default(); // Check for some strings that will be in a model file for sure. If these strings // don't exist, then it's probably not a Yaak file. if !content_str.contains("model") || !content_str.contains("id") { return Ok(None); } let ext = file_path.extension().unwrap_or_default(); if ext == "yml" || ext == "yaml" { Ok(match serde_yaml::from_str::(&content_str) { Ok(m) => Some((m, checksum)), Err(e) => { warn!("Error parsing {:?} {:?}", file_path.file_name(), e); None } }) } else if ext == "json" { Ok(match serde_json::from_str::(&content_str) { Ok(m) => Some((m, checksum)), Err(e) => { warn!("Error parsing {:?} {:?}", file_path.file_name(), e); None } }) } else { Ok(None) } } pub fn from_file(file_path: &Path) -> Result> { let content = match fs::read(file_path) { Ok(c) => c, Err(_) => return Ok(None), }; Self::from_bytes(content, file_path) } pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec, String)> { let ext = rel_path.extension().unwrap_or_default(); let content = if ext == "yaml" || ext == "yml" { serde_yaml::to_string(self)? } else { serde_json::to_string(self)? }; let mut hasher = Sha1::new(); hasher.update(&content); let checksum = hex::encode(hasher.finalize()); Ok((content.into_bytes(), checksum)) } pub fn id(&self) -> String { match self.clone() { SyncModel::Workspace(m) => m.id, SyncModel::Environment(m) => m.id, SyncModel::Folder(m) => m.id, SyncModel::HttpRequest(m) => m.id, SyncModel::GrpcRequest(m) => m.id, SyncModel::WebsocketRequest(m) => m.id, } } pub fn workspace_id(&self) -> String { match self.clone() { SyncModel::Workspace(m) => m.id, SyncModel::Environment(m) => m.workspace_id, SyncModel::Folder(m) => m.workspace_id, SyncModel::HttpRequest(m) => m.workspace_id, SyncModel::GrpcRequest(m) => m.workspace_id, SyncModel::WebsocketRequest(m) => m.workspace_id, } } pub fn updated_at(&self) -> NaiveDateTime { match self.clone() { SyncModel::Workspace(m) => m.updated_at, SyncModel::Environment(m) => m.updated_at, SyncModel::Folder(m) => m.updated_at, SyncModel::HttpRequest(m) => m.updated_at, SyncModel::GrpcRequest(m) => m.updated_at, SyncModel::WebsocketRequest(m) => m.updated_at, } } } impl TryFrom for SyncModel { type Error = crate::error::Error; fn try_from(value: AnyModel) -> Result { let m = match value { AnyModel::Environment(m) => SyncModel::Environment(m), AnyModel::Folder(m) => SyncModel::Folder(m), AnyModel::GrpcRequest(m) => SyncModel::GrpcRequest(m), AnyModel::HttpRequest(m) => SyncModel::HttpRequest(m), AnyModel::WebsocketRequest(m) => SyncModel::WebsocketRequest(m), AnyModel::Workspace(m) => SyncModel::Workspace(m), // Non-sync models AnyModel::CookieJar(m) => return Err(UnknownModel(m.model)), AnyModel::GraphQlIntrospection(m) => return Err(UnknownModel(m.model)), AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)), AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)), AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)), AnyModel::Plugin(m) => return Err(UnknownModel(m.model)), AnyModel::Settings(m) => return Err(UnknownModel(m.model)), AnyModel::WebsocketConnection(m) => return Err(UnknownModel(m.model)), AnyModel::WebsocketEvent(m) => return Err(UnknownModel(m.model)), AnyModel::WorkspaceMeta(m) => return Err(UnknownModel(m.model)), AnyModel::SyncState(m) => return Err(UnknownModel(m.model)), }; Ok(m) } } #[cfg(test)] mod migration_tests { use crate::error::Result; use crate::models::SyncModel; #[test] fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> { let raw = r#" type: environment model: environment id: ev_fAUS49FUN2 workspaceId: wk_kfSI3JDHd7 createdAt: 2025-01-11T17:02:58.012792 updatedAt: 2025-07-23T20:00:46.049649 name: Global Variables public: true base: true variables: [] color: null "#; let m: SyncModel = serde_yaml::from_str(raw)?; match m { SyncModel::Environment(env) => { assert_eq!(env.parent_model, "workspace".to_string()); assert_eq!(env.parent_id, None); } _ => panic!("expected base environment"), } let raw = r#" type: environment model: environment id: ev_fAUS49FUN2 workspaceId: wk_kfSI3JDHd7 createdAt: 2025-01-11T17:02:58.012792 updatedAt: 2025-07-23T20:00:46.049649 name: Global Variables public: true base: false variables: [] color: null "#; let m: SyncModel = serde_yaml::from_str(raw)?; match m { SyncModel::Environment(env) => { assert_eq!(env.parent_model, "environment".to_string()); assert_eq!(env.parent_id, None); } _ => panic!("expected sub environment"), } let raw = r#" type: environment model: environment id: ev_fAUS49FUN2 parentId: fld_123 parentModel: folder workspaceId: wk_kfSI3JDHd7 createdAt: 2025-01-11T17:02:58.012792 updatedAt: 2025-07-23T20:00:46.049649 name: Folder Environment public: true base: false variables: [] color: null "#; let m: SyncModel = serde_yaml::from_str(raw)?; match m { SyncModel::Environment(env) => { assert_eq!(env.parent_model, "folder".to_string()); assert_eq!(env.parent_id, Some("fld_123".to_string())); } _ => panic!("expected folder environment"), } Ok(()) } }