Fix cookie defaults and Timeline ordering, and give models a real Default (#573)

This commit is contained in:
Gregory Schier
2026-08-17 09:14:17 -07:00
committed by GitHub
parent 2021df112a
commit b89c448345
5 changed files with 349 additions and 60 deletions
+6 -9
View File
@@ -435,15 +435,12 @@ fn create(
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?; let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default(); let name = name.unwrap_or_default();
let url = url.unwrap_or_default(); let url = url.unwrap_or_default();
let method = method.unwrap_or_else(|| "GET".to_string()); let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
// Only override the method when one was given; `HttpRequest::default()` is the
let request = HttpRequest { // single place the fallback ("GET") is defined.
workspace_id, if let Some(method) = method {
name, request.method = method.to_uppercase();
method: method.to_uppercase(), }
url,
..Default::default()
};
let created = ctx let created = ctx
.db() .db()
+59
View File
@@ -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"]);
});
+3 -1
View File
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
const modelData = data[modelType] ?? {}; const modelData = data[modelType] ?? {};
return Object.values(modelData).sort( return Object.values(modelData).sort(
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => { (a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
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; return order === "desc" ? n * -1 : n;
}, },
); );
+252 -39
View File
@@ -60,8 +60,22 @@ pub struct ProxySettingAuth {
pub password: String, pub password: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)] impl Default for ClientCertificate {
#[serde(rename_all = "camelCase")] 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")] #[ts(export, export_to = "gen_models.ts")]
pub struct ClientCertificate { pub struct ClientCertificate {
pub host: String, pub host: String,
@@ -75,13 +89,18 @@ pub struct ClientCertificate {
pub pfx_file: Option<String>, pub pfx_file: Option<String>,
#[serde(default)] #[serde(default)]
pub passphrase: Option<String>, pub passphrase: Option<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] impl Default for DnsOverride {
#[serde(rename_all = "camelCase")] 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")] #[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride { pub struct DnsOverride {
pub hostname: String, pub hostname: String,
@@ -89,7 +108,6 @@ pub struct DnsOverride {
pub ipv4: Vec<String>, pub ipv4: Vec<String>,
#[serde(default)] #[serde(default)]
pub ipv6: Vec<String>, pub ipv6: Vec<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
} }
@@ -147,7 +165,6 @@ pub struct InheritedBoolSetting {
#[serde(default)] #[serde(default)]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
#[serde(default = "default_true")]
pub value: bool, 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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "workspaces")] #[enum_def(table_name = "workspaces")]
@@ -403,18 +444,13 @@ pub struct Workspace {
pub encryption_key_challenge: Option<String>, pub encryption_key_challenge: Option<String>,
// Settings // Settings
#[serde(default = "default_true")]
pub setting_validate_certificates: bool, pub setting_validate_certificates: bool,
#[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32, pub setting_request_message_size: i32,
#[serde(default)] #[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>, pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")]
pub setting_send_cookies: bool, pub setting_send_cookies: bool,
#[serde(default = "default_true")]
pub setting_store_cookies: bool, 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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable { pub struct EnvironmentVariable {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
pub name: String, pub name: String,
@@ -949,7 +990,35 @@ pub struct ParentHeaders {
pub headers: Vec<HttpRequestHeader>, pub headers: Vec<HttpRequestHeader>,
} }
#[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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "folders")] #[enum_def(table_name = "folders")]
@@ -974,7 +1043,6 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting, pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting, 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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader { pub struct HttpRequestHeader {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
pub name: String, pub name: String,
@@ -1101,11 +1174,16 @@ pub struct HttpRequestHeader {
pub id: Option<String>, pub id: Option<String>,
} }
#[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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter { pub struct HttpUrlParameter {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")] #[ts(optional, as = "Option<bool>")]
pub enabled: bool, pub enabled: bool,
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` /// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
@@ -1116,7 +1194,36 @@ pub struct HttpUrlParameter {
pub id: Option<String>, pub id: Option<String>,
} }
#[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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "http_requests")] #[enum_def(table_name = "http_requests")]
@@ -1137,7 +1244,6 @@ pub struct HttpRequest {
pub body_type: Option<String>, pub body_type: Option<String>,
pub description: String, pub description: String,
pub headers: Vec<HttpRequestHeader>, pub headers: Vec<HttpRequestHeader>,
#[serde(default = "default_http_method")]
pub method: String, pub method: String,
pub name: String, pub name: String,
pub sort_priority: f64, 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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "websocket_requests")] #[enum_def(table_name = "websocket_requests")]
@@ -1420,7 +1555,6 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting, pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting, pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting, 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")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "grpc_requests")] #[enum_def(table_name = "grpc_requests")]
@@ -2079,7 +2241,6 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure) /// Server URL (http for plaintext or https for secure)
pub url: String, pub url: String,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting, pub setting_request_message_size: InheritedIntSetting,
} }
@@ -2730,22 +2891,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
} }
} }
fn default_true() -> bool { /// Only used as a `from_row` fallback for an unparseable settings column. The
true /// value a *new* model gets comes from that model's `Default` impl.
}
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting { fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE } InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
} }
fn default_http_method() -> String {
"GET".to_string()
}
#[macro_export] #[macro_export]
macro_rules! define_any_model { macro_rules! define_any_model {
($($type:ident),* $(,)?) => { ($($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");
}
}
+29 -11
View File
@@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> {
if workspaces.is_empty() { if workspaces.is_empty() {
workspaces.push(self.upsert_workspace( workspaces.push(self.upsert_workspace(
&Workspace { &Workspace { name: "Yaak".to_string(), ..Default::default() },
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Background, &UpdateSource::Background,
)?) )?)
} }
@@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> {
pub fn default_headers() -> Vec<HttpRequestHeader> { pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![ vec![
HttpRequestHeader { HttpRequestHeader {
enabled: true,
name: "User-Agent".to_string(), name: "User-Agent".to_string(),
value: "yaak".to_string(), value: "yaak".to_string(),
id: None, ..Default::default()
}, },
HttpRequestHeader { HttpRequestHeader {
enabled: true,
name: "Accept".to_string(), name: "Accept".to_string(),
value: "*/*".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
);
}
}