diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index cf8e19be..ec37789a 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -435,15 +435,12 @@ fn create( let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?; let name = name.unwrap_or_default(); let url = url.unwrap_or_default(); - let method = method.unwrap_or_else(|| "GET".to_string()); - - let request = HttpRequest { - workspace_id, - name, - method: method.to_uppercase(), - url, - ..Default::default() - }; + let mut request = HttpRequest { workspace_id, name, url, ..Default::default() }; + // Only override the method when one was given; `HttpRequest::default()` is the + // single place the fallback ("GET") is defined. + if let Some(method) = method { + request.method = method.to_uppercase(); + } let created = ctx .db() diff --git a/crates/yaak-models/guest-js/atoms.test.ts b/crates/yaak-models/guest-js/atoms.test.ts new file mode 100644 index 00000000..517c818c --- /dev/null +++ b/crates/yaak-models/guest-js/atoms.test.ts @@ -0,0 +1,59 @@ +import { createStore } from "jotai"; +import { expect, test } from "vitest"; +import type { HttpResponseEvent } from "../bindings/gen_models"; +import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms"; +import { newStoreData } from "./util"; + +// The five setting events that every send writes, all within the same millisecond +const SETTING_NAMES = [ + "validate_certificates", + "redirects", + "timeout", + "send_cookies", + "store_cookies", +]; + +function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent { + return { + model: "http_response_event", + id, + createdAt, + updatedAt: createdAt, + workspaceId: "wk_1", + responseId: "rs_1", + event: { type: "setting", name, value: "true" }, + }; +} + +test("events with equal createdAt keep store (DB) insertion order", () => { + const store = createStore(); + const data = newStoreData(); + SETTING_NAMES.forEach((name, i) => { + data.http_response_event[`hre_${i}`] = settingEvent( + `hre_${i}`, + name, + "2026-08-17T00:00:00.123", + ); + }); + store.set(modelStoreDataAtom, data); + + const names = store.get(httpResponseEventsAtom).map((e) => { + return e.event.type === "setting" ? e.event.name : e.event.type; + }); + expect(names).toEqual(SETTING_NAMES); +}); + +test("events with distinct createdAt sort ascending", () => { + const store = createStore(); + const data = newStoreData(); + for (const [id, createdAt] of [ + ["hre_b", "2026-08-17T00:00:00.456"], + ["hre_a", "2026-08-17T00:00:00.123"], + ["hre_c", "2026-08-17T00:00:00.789"], + ]) { + data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!); + } + store.set(modelStoreDataAtom, data); + + expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]); +}); diff --git a/crates/yaak-models/guest-js/atoms.ts b/crates/yaak-models/guest-js/atoms.ts index b9dc3e18..24c68234 100644 --- a/crates/yaak-models/guest-js/atoms.ts +++ b/crates/yaak-models/guest-js/atoms.ts @@ -61,7 +61,9 @@ export function createOrderedModelAtom( const modelData = data[modelType] ?? {}; return Object.values(modelData).sort( (a: ExtractModel, b: ExtractModel) => { - const n = a[field] > b[field] ? 1 : -1; + // NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders + // equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order. + const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1; return order === "desc" ? n * -1 : n; }, ); diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 3a779c8c..009c6c1f 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -60,8 +60,22 @@ pub struct ProxySettingAuth { pub password: String, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] +impl Default for ClientCertificate { + fn default() -> Self { + Self { + host: String::new(), + port: None, + crt_file: None, + key_file: None, + pfx_file: None, + passphrase: None, + enabled: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ClientCertificate { pub host: String, @@ -75,13 +89,18 @@ pub struct ClientCertificate { pub pfx_file: Option, #[serde(default)] pub passphrase: Option, - #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +impl Default for DnsOverride { + fn default() -> Self { + Self { hostname: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), enabled: true } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct DnsOverride { pub hostname: String, @@ -89,7 +108,6 @@ pub struct DnsOverride { pub ipv4: Vec, #[serde(default)] pub ipv6: Vec, - #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, } @@ -147,7 +165,6 @@ pub struct InheritedBoolSetting { #[serde(default)] #[ts(optional, as = "Option")] pub enabled: bool, - #[serde(default = "default_true")] pub value: bool, } @@ -383,7 +400,31 @@ impl UpsertModelInfo for Settings { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for Workspace { + fn default() -> Self { + Self { + model: "workspace".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + authentication: BTreeMap::new(), + authentication_type: None, + description: String::new(), + headers: Vec::new(), + name: String::new(), + encryption_key_challenge: None, + setting_validate_certificates: true, + setting_follow_redirects: true, + setting_request_timeout: 0, + setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE, + setting_dns_overrides: Vec::new(), + setting_send_cookies: true, + setting_store_cookies: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "workspaces")] @@ -403,18 +444,13 @@ pub struct Workspace { pub encryption_key_challenge: Option, // Settings - #[serde(default = "default_true")] pub setting_validate_certificates: bool, - #[serde(default = "default_true")] pub setting_follow_redirects: bool, pub setting_request_timeout: i32, - #[serde(default = "default_request_message_size")] pub setting_request_message_size: i32, #[serde(default)] pub setting_dns_overrides: Vec, - #[serde(default = "default_true")] pub setting_send_cookies: bool, - #[serde(default = "default_true")] pub setting_store_cookies: bool, } @@ -920,11 +956,16 @@ impl UpsertModelInfo for Environment { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for EnvironmentVariable { + fn default() -> Self { + Self { enabled: true, name: String::new(), value: String::new(), id: None } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct EnvironmentVariable { - #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, pub name: String, @@ -949,7 +990,35 @@ pub struct ParentHeaders { pub headers: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for Folder { + fn default() -> Self { + Self { + model: "folder".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + workspace_id: String::new(), + folder_id: None, + authentication: BTreeMap::new(), + authentication_type: None, + description: String::new(), + headers: Vec::new(), + name: String::new(), + sort_priority: 0.0, + setting_send_cookies: InheritedBoolSetting::default(), + setting_store_cookies: InheritedBoolSetting::default(), + setting_validate_certificates: InheritedBoolSetting::default(), + setting_follow_redirects: InheritedBoolSetting::default(), + setting_request_timeout: InheritedIntSetting::default(), + setting_request_message_size: InheritedIntSetting { + enabled: false, + value: DEFAULT_REQUEST_MESSAGE_SIZE, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "folders")] @@ -974,7 +1043,6 @@ pub struct Folder { pub setting_validate_certificates: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting, pub setting_request_timeout: InheritedIntSetting, - #[serde(default = "default_request_message_size_setting")] pub setting_request_message_size: InheritedIntSetting, } @@ -1088,11 +1156,16 @@ impl UpsertModelInfo for Folder { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for HttpRequestHeader { + fn default() -> Self { + Self { enabled: true, name: String::new(), value: String::new(), id: None } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpRequestHeader { - #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, pub name: String, @@ -1101,11 +1174,16 @@ pub struct HttpRequestHeader { pub id: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for HttpUrlParameter { + fn default() -> Self { + Self { enabled: true, name: String::new(), value: String::new(), id: None } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpUrlParameter { - #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, /// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` @@ -1116,7 +1194,36 @@ pub struct HttpUrlParameter { pub id: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for HttpRequest { + fn default() -> Self { + Self { + model: "http_request".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + workspace_id: String::new(), + folder_id: None, + authentication: BTreeMap::new(), + authentication_type: None, + body: BTreeMap::new(), + body_type: None, + description: String::new(), + headers: Vec::new(), + method: "GET".to_string(), + name: String::new(), + sort_priority: 0.0, + url: String::new(), + url_parameters: Vec::new(), + setting_send_cookies: InheritedBoolSetting::default(), + setting_store_cookies: InheritedBoolSetting::default(), + setting_validate_certificates: InheritedBoolSetting::default(), + setting_follow_redirects: InheritedBoolSetting::default(), + setting_request_timeout: InheritedIntSetting::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "http_requests")] @@ -1137,7 +1244,6 @@ pub struct HttpRequest { pub body_type: Option, pub description: String, pub headers: Vec, - #[serde(default = "default_http_method")] pub method: String, pub name: String, pub sort_priority: f64, @@ -1393,7 +1499,36 @@ impl Default for WebsocketMessageType { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for WebsocketRequest { + fn default() -> Self { + Self { + model: "websocket_request".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + workspace_id: String::new(), + folder_id: None, + authentication: BTreeMap::new(), + authentication_type: None, + description: String::new(), + headers: Vec::new(), + message: String::new(), + name: String::new(), + sort_priority: 0.0, + url: String::new(), + url_parameters: Vec::new(), + setting_send_cookies: InheritedBoolSetting::default(), + setting_store_cookies: InheritedBoolSetting::default(), + setting_validate_certificates: InheritedBoolSetting::default(), + setting_request_message_size: InheritedIntSetting { + enabled: false, + value: DEFAULT_REQUEST_MESSAGE_SIZE, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "websocket_requests")] @@ -1420,7 +1555,6 @@ pub struct WebsocketRequest { pub setting_send_cookies: InheritedBoolSetting, pub setting_store_cookies: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting, - #[serde(default = "default_request_message_size_setting")] pub setting_request_message_size: InheritedIntSetting, } @@ -2053,7 +2187,35 @@ impl UpsertModelInfo for GraphQlIntrospection { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] +impl Default for GrpcRequest { + fn default() -> Self { + Self { + model: "grpc_request".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + workspace_id: String::new(), + folder_id: None, + authentication_type: None, + authentication: BTreeMap::new(), + description: String::new(), + message: String::new(), + metadata: Vec::new(), + method: None, + name: String::new(), + service: None, + sort_priority: 0.0, + url: String::new(), + setting_validate_certificates: InheritedBoolSetting::default(), + setting_request_message_size: InheritedIntSetting { + enabled: false, + value: DEFAULT_REQUEST_MESSAGE_SIZE, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "grpc_requests")] @@ -2079,7 +2241,6 @@ pub struct GrpcRequest { /// Server URL (http for plaintext or https for secure) pub url: String, pub setting_validate_certificates: InheritedBoolSetting, - #[serde(default = "default_request_message_size_setting")] pub setting_request_message_size: InheritedIntSetting, } @@ -2730,22 +2891,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue { } } -fn default_true() -> bool { - true -} - -fn default_request_message_size() -> i32 { - DEFAULT_REQUEST_MESSAGE_SIZE -} - +/// Only used as a `from_row` fallback for an unparseable settings column. The +/// value a *new* model gets comes from that model's `Default` impl. fn default_request_message_size_setting() -> InheritedIntSetting { InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE } } -fn default_http_method() -> String { - "GET".to_string() -} - #[macro_export] macro_rules! define_any_model { ($($type:ident),* $(,)?) => { @@ -2889,3 +3040,65 @@ impl AnyModel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every model below carries `#[serde(default)]` at the container level, so a + /// missing key is filled from `Default::default()`, which makes each `Default` + /// impl the single definition of that model's defaults. + /// + /// Deserializing `{}` therefore equals `Default::default()` by construction + /// today. What this catches is the two ways that can come apart again, both of + /// which have already bitten us: + /// + /// 1. A field-level `#[serde(default = "...")]` (or bare `#[serde(default)]`) + /// added back on a field whose `Default` says something else. That is exactly + /// the shape of the bug this replaced: `setting_send_cookies` deserialized as + /// true but a derived `Default` produced false, so the bootstrapped workspace + /// silently sent no cookies. + /// 2. The container-level `#[serde(default)]` being dropped, which turns every + /// missing key into a deserialization error instead. + macro_rules! assert_default_matches_serde { + ($($t:ty),+ $(,)?) => { + $( + assert_eq!( + serde_json::from_str::<$t>("{}").expect(concat!( + stringify!($t), + " must deserialize from an empty object" + )), + <$t>::default(), + concat!(stringify!($t), ": Default::default() disagrees with its serde defaults"), + ); + )+ + }; + } + + #[test] + fn defaults_match_serde_defaults() { + assert_default_matches_serde!( + Workspace, + HttpRequest, + Folder, + GrpcRequest, + WebsocketRequest, + HttpRequestHeader, + HttpUrlParameter, + EnvironmentVariable, + DnsOverride, + ClientCertificate, + InheritedBoolSetting, + InheritedIntSetting, + ); + } + + #[test] + fn defaults_carry_their_model_name() { + assert_eq!(Workspace::default().model, "workspace"); + assert_eq!(HttpRequest::default().model, "http_request"); + assert_eq!(Folder::default().model, "folder"); + assert_eq!(GrpcRequest::default().model, "grpc_request"); + assert_eq!(WebsocketRequest::default().model, "websocket_request"); + } +} diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index b7874c35..50deb620 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> { if workspaces.is_empty() { workspaces.push(self.upsert_workspace( - &Workspace { - name: "Yaak".to_string(), - setting_follow_redirects: true, - setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE, - setting_validate_certificates: true, - ..Default::default() - }, + &Workspace { name: "Yaak".to_string(), ..Default::default() }, &UpdateSource::Background, )?) } @@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> { pub fn default_headers() -> Vec { vec![ HttpRequestHeader { - enabled: true, name: "User-Agent".to_string(), value: "yaak".to_string(), - id: None, + ..Default::default() }, HttpRequestHeader { - enabled: true, name: "Accept".to_string(), value: "*/*".to_string(), - id: None, + ..Default::default() }, ] } + +#[cfg(test)] +mod tests { + use crate::init_in_memory; + + #[test] + fn bootstraps_first_workspace_with_real_defaults() { + let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + + let workspaces = db.list_workspaces().expect("Failed to list workspaces"); + let workspace = workspaces.first().expect("No workspace was bootstrapped"); + + // This workspace is built in Rust and never deserialized, so it only gets + // these values if `Workspace::default()` carries them. Asserted through the + // DB round trip, since the column values are what a fresh install 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 + ); + } +}