From beb47a6b6a095bf6c8db74bd2d226bc55bb73875 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 19 Jan 2026 07:29:00 -0800 Subject: [PATCH] Refactor default headers to be injected dynamically (#367) --- crates-tauri/yaak-app/src/commands.rs | 7 +++ crates-tauri/yaak-app/src/lib.rs | 1 + ...45146_remove-default-workspace-headers.sql | 12 +++++ .../yaak-models/src/queries/grpc_requests.rs | 3 +- .../yaak-models/src/queries/http_requests.rs | 3 +- crates/yaak-models/src/queries/mod.rs | 22 ++++++++- .../src/queries/websocket_requests.rs | 3 +- crates/yaak-models/src/queries/workspaces.rs | 47 ++++++++++--------- src-web/components/HeadersEditor.tsx | 14 +++++- .../components/WorkspaceSettingsDialog.tsx | 1 + src-web/hooks/useInheritedHeaders.ts | 17 +++++-- src-web/lib/defaultHeaders.ts | 8 ++++ src-web/lib/tauri.ts | 1 + 13 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql create mode 100644 src-web/lib/defaultHeaders.ts diff --git a/crates-tauri/yaak-app/src/commands.rs b/crates-tauri/yaak-app/src/commands.rs index db3fa878..084b3a20 100644 --- a/crates-tauri/yaak-app/src/commands.rs +++ b/crates-tauri/yaak-app/src/commands.rs @@ -4,6 +4,8 @@ use std::sync::Arc; use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use yaak_crypto::manager::EncryptionManager; +use yaak_models::models::HttpRequestHeader; +use yaak_models::queries::workspaces::default_headers; use yaak_plugins::events::GetThemesResponse; use yaak_plugins::manager::PluginManager; use yaak_plugins::native_template_functions::{ @@ -97,3 +99,8 @@ pub(crate) async fn cmd_set_workspace_key( window.crypto().set_human_key(workspace_id, key)?; Ok(()) } + +#[command] +pub(crate) fn cmd_default_headers() -> Vec { + default_headers() +} diff --git a/crates-tauri/yaak-app/src/lib.rs b/crates-tauri/yaak-app/src/lib.rs index 0f2c465f..a00da2f7 100644 --- a/crates-tauri/yaak-app/src/lib.rs +++ b/crates-tauri/yaak-app/src/lib.rs @@ -1718,6 +1718,7 @@ pub fn run() { // // Migrated commands crate::commands::cmd_decrypt_template, + crate::commands::cmd_default_headers, crate::commands::cmd_enable_encryption, crate::commands::cmd_get_themes, crate::commands::cmd_reveal_workspace_key, diff --git a/crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql b/crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql new file mode 100644 index 00000000..44c71f95 --- /dev/null +++ b/crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql @@ -0,0 +1,12 @@ +-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*), +-- keeping any other custom headers the user may have added. +UPDATE workspaces +SET headers = ( + SELECT json_group_array(json(value)) + FROM json_each(headers) + WHERE NOT ( + (LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak') + OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*') + ) +) +WHERE json_array_length(headers) > 0; diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index 10289b44..003f883e 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -1,3 +1,4 @@ +use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader}; @@ -87,6 +88,6 @@ impl<'a> DbContext<'a> { metadata.append(&mut grpc_request.metadata.clone()); - Ok(metadata) + Ok(dedupe_headers(metadata)) } } diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index a4d6fe21..c0de36a0 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -1,3 +1,4 @@ +use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden}; @@ -87,7 +88,7 @@ impl<'a> DbContext<'a> { headers.append(&mut http_request.headers.clone()); - Ok(headers) + Ok(dedupe_headers(headers)) } pub fn list_http_requests_for_folder_recursive( diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index fd1553d9..2b89c233 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -19,6 +19,26 @@ mod websocket_connections; mod websocket_events; mod websocket_requests; mod workspace_metas; -mod workspaces; +pub mod workspaces; const MAX_HISTORY_ITEMS: usize = 20; + +use crate::models::HttpRequestHeader; +use std::collections::HashMap; + +/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. +/// Preserves the order of first occurrence for each header name. +pub(crate) fn dedupe_headers(headers: Vec) -> Vec { + let mut index_by_name: HashMap = HashMap::new(); + let mut deduped: Vec = Vec::new(); + for header in headers { + let key = header.name.to_lowercase(); + if let Some(&idx) = index_by_name.get(&key) { + deduped[idx] = header; + } else { + index_by_name.insert(key, deduped.len()); + deduped.push(header); + } + } + deduped +} diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index c45498a9..1f9ecb36 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -1,3 +1,4 @@ +use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden}; @@ -95,6 +96,6 @@ impl<'a> DbContext<'a> { headers.append(&mut websocket_request.headers.clone()); - Ok(headers) + Ok(dedupe_headers(headers)) } } diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 682a608e..978caade 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -65,28 +65,7 @@ impl<'a> DbContext<'a> { } pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result { - let mut workspace = w.clone(); - - // Add default headers only for NEW workspaces (empty ID means insert, not update) - // This prevents re-adding headers if a user intentionally removes all headers - if workspace.id.is_empty() && workspace.headers.is_empty() { - workspace.headers = vec![ - HttpRequestHeader { - enabled: true, - name: "User-Agent".to_string(), - value: "yaak".to_string(), - id: None, - }, - HttpRequestHeader { - enabled: true, - name: "Accept".to_string(), - value: "*/*".to_string(), - id: None, - }, - ]; - } - - self.upsert(&workspace, source) + self.upsert(w, source) } pub fn resolve_auth_for_workspace( @@ -101,6 +80,28 @@ impl<'a> DbContext<'a> { } pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { - workspace.headers.clone() + let mut headers = default_headers(); + headers.extend(workspace.headers.clone()); + headers } } + +/// 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 { + vec![ + HttpRequestHeader { + enabled: true, + name: "User-Agent".to_string(), + value: "yaak".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: true, + name: "Accept".to_string(), + value: "*/*".to_string(), + id: None, + }, + ] +} diff --git a/src-web/components/HeadersEditor.tsx b/src-web/components/HeadersEditor.tsx index 0e585e94..05d08ac5 100644 --- a/src-web/components/HeadersEditor.tsx +++ b/src-web/components/HeadersEditor.tsx @@ -19,6 +19,7 @@ type Props = { forceUpdateKey: string; headers: HttpRequestHeader[]; inheritedHeaders?: HttpRequestHeader[]; + inheritedHeadersLabel?: string; stateKey: string; onChange: (headers: HttpRequestHeader[]) => void; label?: string; @@ -28,11 +29,20 @@ export function HeadersEditor({ stateKey, headers, inheritedHeaders, + inheritedHeadersLabel = 'Inherited', onChange, forceUpdateKey, }: Props) { + // Get header names defined at current level (case-insensitive) + const currentHeaderNames = new Set( + headers.filter((h) => h.name).map((h) => h.name.toLowerCase()), + ); + // Filter inherited headers: must be enabled, have content, and not be overridden by current level const validInheritedHeaders = - inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? []; + inheritedHeaders?.filter( + (pair) => + pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()), + ) ?? []; const hasInheritedHeaders = validInheritedHeaders.length > 0; return (
- Inherited + {inheritedHeadersLabel} } > diff --git a/src-web/components/WorkspaceSettingsDialog.tsx b/src-web/components/WorkspaceSettingsDialog.tsx index 2d825c05..f53f0253 100644 --- a/src-web/components/WorkspaceSettingsDialog.tsx +++ b/src-web/components/WorkspaceSettingsDialog.tsx @@ -95,6 +95,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) { patchModel(workspace, { headers })} diff --git a/src-web/hooks/useInheritedHeaders.ts b/src-web/hooks/useInheritedHeaders.ts index 2dcfb778..a60610f0 100644 --- a/src-web/hooks/useInheritedHeaders.ts +++ b/src-web/hooks/useInheritedHeaders.ts @@ -8,6 +8,7 @@ import type { } from '@yaakapp-internal/models'; import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai'; +import { defaultHeaders } from '../lib/defaultHeaders'; const ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]); @@ -17,12 +18,12 @@ export function useInheritedHeaders(baseModel: HeaderModel | null) { const parents = useAtomValue(ancestorsAtom); if (baseModel == null) return []; - if (baseModel.model === 'workspace') return []; + if (baseModel.model === 'workspace') return defaultHeaders; const next = (child: HeaderModel): HttpRequestHeader[] => { - // Short-circuit + // Short-circuit at workspace level - return global defaults + workspace headers if (child.model === 'workspace') { - return []; + return [...defaultHeaders, ...child.headers]; } // Recurse up the tree @@ -40,5 +41,13 @@ export function useInheritedHeaders(baseModel: HeaderModel | null) { return [...headers, ...parent.headers]; }; - return next(baseModel); + const allHeaders = next(baseModel); + + // Deduplicate by header name (case-insensitive), keeping the latest (most specific) value + const headersByName = new Map(); + for (const header of allHeaders) { + headersByName.set(header.name.toLowerCase(), header); + } + + return Array.from(headersByName.values()); } diff --git a/src-web/lib/defaultHeaders.ts b/src-web/lib/defaultHeaders.ts new file mode 100644 index 00000000..57f6153e --- /dev/null +++ b/src-web/lib/defaultHeaders.ts @@ -0,0 +1,8 @@ +import type { HttpRequestHeader } from '@yaakapp-internal/models'; +import { invokeCmd } from './tauri'; + +/** + * Global default headers fetched from the backend. + * These are static and fetched once on module load. + */ +export const defaultHeaders: HttpRequestHeader[] = await invokeCmd('cmd_default_headers'); diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 162fd7df..9302e52d 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -12,6 +12,7 @@ type TauriCmd = | 'cmd_create_grpc_request' | 'cmd_curl_to_request' | 'cmd_decrypt_template' + | 'cmd_default_headers' | 'cmd_delete_all_grpc_connections' | 'cmd_delete_all_http_responses' | 'cmd_delete_send_history'