mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-24 17:48:30 +02:00
Store and show request body in UI (#327)
This commit is contained in:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,5 +1,7 @@
|
|||||||
src-tauri/vendored/**/* linguist-generated=true
|
src-tauri/vendored/**/* linguist-generated=true
|
||||||
src-tauri/gen/schemas/**/* linguist-generated=true
|
src-tauri/gen/schemas/**/* linguist-generated=true
|
||||||
|
**/bindings/* linguist-generated=true
|
||||||
|
src-tauri/yaak-templates/pkg/* linguist-generated=true
|
||||||
|
|
||||||
# Ensure consistent line endings for test files that check exact content
|
# Ensure consistent line endings for test files that check exact content
|
||||||
src-tauri/yaak-http/tests/test.txt text eol=lf
|
src-tauri/yaak-http/tests/test.txt text eol=lf
|
||||||
|
|||||||
57
.github/workflows/claude-code-review.yml
vendored
57
.github/workflows/claude-code-review.yml
vendored
@@ -1,57 +0,0 @@
|
|||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
prompt: |
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
|
|
||||||
Please review this pull request and provide feedback on:
|
|
||||||
- Code quality and best practices
|
|
||||||
- Potential bugs or issues
|
|
||||||
- Performance considerations
|
|
||||||
- Security concerns
|
|
||||||
- Test coverage
|
|
||||||
|
|
||||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
|
||||||
|
|
||||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
|
||||||
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::render::render_http_request;
|
use crate::render::render_http_request;
|
||||||
use crate::response_err;
|
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use reqwest_cookie_store::{CookieStore, CookieStoreMutex};
|
use reqwest_cookie_store::{CookieStore, CookieStoreMutex};
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||||
use tokio::fs::{File, create_dir_all};
|
use tokio::fs::{File, create_dir_all};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::sync::watch::Receiver;
|
use tokio::sync::watch::Receiver;
|
||||||
|
use tokio_util::bytes::Bytes;
|
||||||
use yaak_http::client::{
|
use yaak_http::client::{
|
||||||
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
|
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
|
||||||
};
|
};
|
||||||
use yaak_http::manager::HttpConnectionManager;
|
use yaak_http::manager::HttpConnectionManager;
|
||||||
use yaak_http::sender::ReqwestSender;
|
use yaak_http::sender::ReqwestSender;
|
||||||
|
use yaak_http::tee_reader::TeeReader;
|
||||||
use yaak_http::transaction::HttpTransaction;
|
use yaak_http::transaction::HttpTransaction;
|
||||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params};
|
use yaak_http::types::{
|
||||||
|
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
|
||||||
|
};
|
||||||
|
use yaak_models::blob_manager::{BlobManagerExt, BodyChunk};
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent,
|
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent,
|
||||||
HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
|
HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||||
@@ -32,6 +36,55 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
|||||||
use yaak_templates::RenderOptions;
|
use yaak_templates::RenderOptions;
|
||||||
use yaak_tls::find_client_certificate;
|
use yaak_tls::find_client_certificate;
|
||||||
|
|
||||||
|
/// Chunk size for storing request bodies (1MB)
|
||||||
|
const REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Context for managing response state during HTTP transactions.
|
||||||
|
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
|
||||||
|
struct ResponseContext<R: Runtime> {
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
response: HttpResponse,
|
||||||
|
update_source: UpdateSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime> ResponseContext<R> {
|
||||||
|
fn new(app_handle: AppHandle<R>, response: HttpResponse, update_source: UpdateSource) -> Self {
|
||||||
|
Self { app_handle, response, update_source }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this response is persisted (has a non-empty ID)
|
||||||
|
fn is_persisted(&self) -> bool {
|
||||||
|
!self.response.id.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the response state. For persisted responses, fetches from DB, applies the
|
||||||
|
/// closure, and updates the DB. For ephemeral responses, just applies the closure
|
||||||
|
/// to the in-memory response.
|
||||||
|
fn update<F>(&mut self, func: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut HttpResponse),
|
||||||
|
{
|
||||||
|
if self.is_persisted() {
|
||||||
|
let r = self.app_handle.with_tx(|tx| {
|
||||||
|
let mut r = tx.get_http_response(&self.response.id)?;
|
||||||
|
func(&mut r);
|
||||||
|
tx.update_http_response_if_id(&r, &self.update_source)?;
|
||||||
|
Ok(r)
|
||||||
|
})?;
|
||||||
|
self.response = r;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
func(&mut self.response);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current response state
|
||||||
|
fn response(&self) -> &HttpResponse {
|
||||||
|
&self.response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_http_request<R: Runtime>(
|
pub async fn send_http_request<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
unrendered_request: &HttpRequest,
|
unrendered_request: &HttpRequest,
|
||||||
@@ -62,25 +115,38 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
let response = Arc::new(Mutex::new(og_response.clone()));
|
|
||||||
let update_source = UpdateSource::from_window(window);
|
let update_source = UpdateSource::from_window(window);
|
||||||
|
let mut response_ctx =
|
||||||
|
ResponseContext::new(app_handle.clone(), og_response.clone(), update_source);
|
||||||
|
|
||||||
// Execute the inner send logic and handle errors consistently
|
// Execute the inner send logic and handle errors consistently
|
||||||
|
let start = Instant::now();
|
||||||
let result = send_http_request_inner(
|
let result = send_http_request_inner(
|
||||||
window,
|
window,
|
||||||
unrendered_request,
|
unrendered_request,
|
||||||
og_response,
|
|
||||||
environment,
|
environment,
|
||||||
cookie_jar,
|
cookie_jar,
|
||||||
cancelled_rx,
|
cancelled_rx,
|
||||||
plugin_context,
|
plugin_context,
|
||||||
|
&mut response_ctx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => Ok(response),
|
Ok(response) => Ok(response),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
Ok(response_err(&app_handle, &*response.lock().await, e.to_string(), &update_source))
|
let error = e.to_string();
|
||||||
|
let elapsed = start.elapsed().as_millis() as i32;
|
||||||
|
warn!("Failed to send request: {error:?}");
|
||||||
|
let _ = response_ctx.update(|r| {
|
||||||
|
r.state = HttpResponseState::Closed;
|
||||||
|
r.elapsed = elapsed;
|
||||||
|
if r.elapsed_headers == 0 {
|
||||||
|
r.elapsed_headers = elapsed;
|
||||||
|
}
|
||||||
|
r.error = Some(error);
|
||||||
|
});
|
||||||
|
Ok(response_ctx.response().clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,26 +154,24 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
async fn send_http_request_inner<R: Runtime>(
|
async fn send_http_request_inner<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
unrendered_request: &HttpRequest,
|
unrendered_request: &HttpRequest,
|
||||||
og_response: &HttpResponse,
|
|
||||||
environment: Option<Environment>,
|
environment: Option<Environment>,
|
||||||
cookie_jar: Option<CookieJar>,
|
cookie_jar: Option<CookieJar>,
|
||||||
cancelled_rx: &Receiver<bool>,
|
cancelled_rx: &Receiver<bool>,
|
||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
|
response_ctx: &mut ResponseContext<R>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
let plugin_manager = app_handle.state::<PluginManager>();
|
let plugin_manager = app_handle.state::<PluginManager>();
|
||||||
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
||||||
let settings = window.db().get_settings();
|
let settings = window.db().get_settings();
|
||||||
let wrk_id = &unrendered_request.workspace_id;
|
let workspace_id = &unrendered_request.workspace_id;
|
||||||
let fld_id = unrendered_request.folder_id.as_deref();
|
let folder_id = unrendered_request.folder_id.as_deref();
|
||||||
let env_id = environment.map(|e| e.id);
|
let environment_id = environment.map(|e| e.id);
|
||||||
let resp_id = og_response.id.clone();
|
let workspace = window.db().get_workspace(workspace_id)?;
|
||||||
let workspace = window.db().get_workspace(wrk_id)?;
|
|
||||||
let response = Arc::new(Mutex::new(og_response.clone()));
|
|
||||||
let update_source = UpdateSource::from_window(window);
|
|
||||||
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
|
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
|
||||||
let cb = PluginTemplateCallback::new(window.app_handle(), &plugin_context, RenderPurpose::Send);
|
let cb = PluginTemplateCallback::new(window.app_handle(), &plugin_context, RenderPurpose::Send);
|
||||||
let env_chain = window.db().resolve_environments(&workspace.id, fld_id, env_id.as_deref())?;
|
let env_chain =
|
||||||
|
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
||||||
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
||||||
|
|
||||||
// Build the sendable request using the new SendableHttpRequest type
|
// Build the sendable request using the new SendableHttpRequest type
|
||||||
@@ -195,17 +259,30 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let start_for_cancellation = Instant::now();
|
let result =
|
||||||
let final_resp = execute_transaction(
|
execute_transaction(client, sendable_request, response_ctx, cancelled_rx.clone()).await;
|
||||||
client,
|
|
||||||
sendable_request,
|
// Wait for blob writing to complete and check for errors
|
||||||
response.clone(),
|
let final_result = match result {
|
||||||
&resp_id,
|
Ok((response, maybe_blob_write_handle)) => {
|
||||||
&app_handle,
|
// Check if blob writing failed
|
||||||
&update_source,
|
if let Some(handle) = maybe_blob_write_handle {
|
||||||
cancelled_rx.clone(),
|
if let Ok(Err(e)) = handle.await {
|
||||||
)
|
// Update response with the storage error
|
||||||
.await;
|
let _ = response_ctx.update(|r| {
|
||||||
|
let error_msg =
|
||||||
|
format!("Request succeeded but failed to store request body: {}", e);
|
||||||
|
r.error = Some(match &r.error {
|
||||||
|
Some(existing) => format!("{}; {}", existing, error_msg),
|
||||||
|
None => error_msg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
// Persist cookies back to the database after the request completes
|
// Persist cookies back to the database after the request completes
|
||||||
if let Some((cookie_store, mut cj)) = maybe_cookie_manager {
|
if let Some((cookie_store, mut cj)) = maybe_cookie_manager {
|
||||||
@@ -220,7 +297,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
cj.cookies = cookies;
|
cj.cookies = cookies;
|
||||||
if let Err(e) = window.db().upsert_cookie_jar(&cj, &update_source) {
|
if let Err(e) = window.db().upsert_cookie_jar(&cj, &UpdateSource::Background) {
|
||||||
warn!("Failed to persist cookies to database: {}", e);
|
warn!("Failed to persist cookies to database: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,23 +307,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match final_resp {
|
final_result
|
||||||
Ok(r) => Ok(r),
|
|
||||||
Err(e) => match app_handle.db().get_http_response(&resp_id) {
|
|
||||||
Ok(mut r) => {
|
|
||||||
r.state = HttpResponseState::Closed;
|
|
||||||
r.elapsed = start_for_cancellation.elapsed().as_millis() as i32;
|
|
||||||
r.elapsed_headers = start_for_cancellation.elapsed().as_millis() as i32;
|
|
||||||
r.error = Some(e.to_string());
|
|
||||||
app_handle
|
|
||||||
.db()
|
|
||||||
.update_http_response_if_id(&r, &UpdateSource::from_window(window))
|
|
||||||
.expect("Failed to update response");
|
|
||||||
Ok(r)
|
|
||||||
}
|
|
||||||
_ => Err(GenericError("Ephemeral request was cancelled".to_string())),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_http_request<R: Runtime>(
|
pub fn resolve_http_request<R: Runtime>(
|
||||||
@@ -268,13 +329,15 @@ pub fn resolve_http_request<R: Runtime>(
|
|||||||
|
|
||||||
async fn execute_transaction<R: Runtime>(
|
async fn execute_transaction<R: Runtime>(
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
sendable_request: SendableHttpRequest,
|
mut sendable_request: SendableHttpRequest,
|
||||||
response: Arc<Mutex<HttpResponse>>,
|
response_ctx: &mut ResponseContext<R>,
|
||||||
response_id: &String,
|
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
update_source: &UpdateSource,
|
|
||||||
mut cancelled_rx: Receiver<bool>,
|
mut cancelled_rx: Receiver<bool>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<(HttpResponse, Option<tauri::async_runtime::JoinHandle<Result<()>>>)> {
|
||||||
|
let app_handle = &response_ctx.app_handle.clone();
|
||||||
|
let response_id = response_ctx.response().id.clone();
|
||||||
|
let workspace_id = response_ctx.response().workspace_id.clone();
|
||||||
|
let is_persisted = response_ctx.is_persisted();
|
||||||
|
|
||||||
let sender = ReqwestSender::with_client(client);
|
let sender = ReqwestSender::with_client(client);
|
||||||
let transaction = HttpTransaction::new(sender);
|
let transaction = HttpTransaction::new(sender);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -286,30 +349,85 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
{
|
// Update response with headers info
|
||||||
// Update response with headers info and mark as connected
|
response_ctx.update(|r| {
|
||||||
let mut r = response.lock().await;
|
|
||||||
r.url = sendable_request.url.clone();
|
r.url = sendable_request.url.clone();
|
||||||
r.request_headers = request_headers.clone();
|
r.request_headers = request_headers;
|
||||||
app_handle.db().update_http_response_if_id(&r, &update_source)?;
|
})?;
|
||||||
}
|
|
||||||
|
|
||||||
// Create channel for receiving events and spawn a task to store them in DB
|
// Create bounded channel for receiving events and spawn a task to store them in DB
|
||||||
|
// Buffer size of 100 events provides backpressure if DB writes are slow
|
||||||
let (event_tx, mut event_rx) =
|
let (event_tx, mut event_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<yaak_http::sender::HttpResponseEvent>();
|
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
|
||||||
|
|
||||||
// Write events to DB in a task
|
// Write events to DB in a task (only for persisted responses)
|
||||||
{
|
if is_persisted {
|
||||||
let response_id = response_id.clone();
|
let response_id = response_id.clone();
|
||||||
let workspace_id = response.lock().await.workspace_id.clone();
|
|
||||||
let app_handle = app_handle.clone();
|
let app_handle = app_handle.clone();
|
||||||
let update_source = update_source.clone();
|
let update_source = response_ctx.update_source.clone();
|
||||||
|
let workspace_id = workspace_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = event_rx.recv().await {
|
while let Some(event) = event_rx.recv().await {
|
||||||
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
|
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
|
||||||
let _ = app_handle.db().upsert(&db_event, &update_source);
|
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// For ephemeral responses, just drain the events
|
||||||
|
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture request body as it's sent (only for persisted responses)
|
||||||
|
let body_id = format!("{}.request", response_id);
|
||||||
|
let maybe_blob_write_handle = match sendable_request.body {
|
||||||
|
Some(SendableBody::Bytes(bytes)) => {
|
||||||
|
if is_persisted {
|
||||||
|
write_bytes_to_db_sync(response_ctx, &body_id, bytes.clone())?;
|
||||||
|
}
|
||||||
|
sendable_request.body = Some(SendableBody::Bytes(bytes));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Some(SendableBody::Stream(stream)) => {
|
||||||
|
// Wrap stream with TeeReader to capture data as it's read
|
||||||
|
// Bounded channel with buffer size of 10 chunks (~10MB) provides backpressure
|
||||||
|
let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(10);
|
||||||
|
let tee_reader = TeeReader::new(stream, body_chunk_tx);
|
||||||
|
let pinned: Pin<Box<dyn AsyncRead + Send + 'static>> = Box::pin(tee_reader);
|
||||||
|
|
||||||
|
let handle = if is_persisted {
|
||||||
|
// Spawn task to write request body chunks to blob DB
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
let response_id = response_id.clone();
|
||||||
|
let workspace_id = workspace_id.clone();
|
||||||
|
let body_id = body_id.clone();
|
||||||
|
let update_source = response_ctx.update_source.clone();
|
||||||
|
Some(tauri::async_runtime::spawn(async move {
|
||||||
|
write_stream_chunks_to_db(
|
||||||
|
app_handle,
|
||||||
|
&body_id,
|
||||||
|
&workspace_id,
|
||||||
|
&response_id,
|
||||||
|
&update_source,
|
||||||
|
body_chunk_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// For ephemeral responses, just drain the body chunks
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut rx = body_chunk_rx;
|
||||||
|
while rx.recv().await.is_some() {}
|
||||||
|
});
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
sendable_request.body = Some(SendableBody::Stream(pinned));
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sendable_request.body = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute the transaction with cancellation support
|
// Execute the transaction with cancellation support
|
||||||
@@ -320,44 +438,42 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Prepare the response path before consuming the body
|
// Prepare the response path before consuming the body
|
||||||
let dir = app_handle.path().app_data_dir()?;
|
|
||||||
let base_dir = dir.join("responses");
|
|
||||||
create_dir_all(&base_dir).await?;
|
|
||||||
|
|
||||||
let body_path = if response_id.is_empty() {
|
let body_path = if response_id.is_empty() {
|
||||||
base_dir.join(uuid::Uuid::new_v4().to_string())
|
// Ephemeral responses: use OS temp directory for automatic cleanup
|
||||||
|
let temp_dir = std::env::temp_dir().join("yaak-ephemeral-responses");
|
||||||
|
create_dir_all(&temp_dir).await?;
|
||||||
|
temp_dir.join(uuid::Uuid::new_v4().to_string())
|
||||||
} else {
|
} else {
|
||||||
|
// Persisted responses: use app data directory
|
||||||
|
let dir = app_handle.path().app_data_dir()?;
|
||||||
|
let base_dir = dir.join("responses");
|
||||||
|
create_dir_all(&base_dir).await?;
|
||||||
base_dir.join(&response_id)
|
base_dir.join(&response_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract metadata before consuming the body (headers are available immediately)
|
// Extract metadata before consuming the body (headers are available immediately)
|
||||||
// Url might change, so update again
|
// Url might change, so update again
|
||||||
let headers: Vec<HttpResponseHeader> = http_response
|
response_ctx.update(|r| {
|
||||||
.headers
|
|
||||||
.iter()
|
|
||||||
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
{
|
|
||||||
// Update response with headers info and mark as connected
|
|
||||||
let mut r = response.lock().await;
|
|
||||||
r.body_path = Some(body_path.to_string_lossy().to_string());
|
r.body_path = Some(body_path.to_string_lossy().to_string());
|
||||||
r.elapsed_headers = start.elapsed().as_millis() as i32;
|
r.elapsed_headers = start.elapsed().as_millis() as i32;
|
||||||
r.status = http_response.status as i32;
|
r.status = http_response.status as i32;
|
||||||
r.status_reason = http_response.status_reason.clone().clone();
|
r.status_reason = http_response.status_reason.clone();
|
||||||
r.url = http_response.url.clone().clone();
|
r.url = http_response.url.clone();
|
||||||
r.remote_addr = http_response.remote_addr.clone();
|
r.remote_addr = http_response.remote_addr.clone();
|
||||||
r.version = http_response.version.clone().clone();
|
r.version = http_response.version.clone();
|
||||||
r.headers = headers.clone();
|
r.headers = http_response
|
||||||
r.content_length = http_response.content_length.map(|l| l as i32);
|
.headers
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
||||||
|
.collect();
|
||||||
|
r.content_length = http_response.content_length.map(|l| l as i64);
|
||||||
|
r.state = HttpResponseState::Connected;
|
||||||
r.request_headers = http_response
|
r.request_headers = http_response
|
||||||
.request_headers
|
.request_headers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() })
|
.map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() })
|
||||||
.collect();
|
.collect();
|
||||||
r.state = HttpResponseState::Connected;
|
})?;
|
||||||
app_handle.db().update_http_response_if_id(&r, &update_source)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the body stream for manual consumption
|
// Get the body stream for manual consumption
|
||||||
let mut body_stream = http_response.into_body_stream()?;
|
let mut body_stream = http_response.into_body_stream()?;
|
||||||
@@ -371,10 +487,14 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| GenericError(format!("Failed to open file: {}", e)))?;
|
.map_err(|e| GenericError(format!("Failed to open file: {}", e)))?;
|
||||||
|
|
||||||
// Stream body to file, updating DB on each chunk
|
// Stream body to file, with throttled DB updates to avoid excessive writes
|
||||||
let mut written_bytes: usize = 0;
|
let mut written_bytes: usize = 0;
|
||||||
|
let mut last_update_time = start;
|
||||||
let mut buf = [0u8; 8192];
|
let mut buf = [0u8; 8192];
|
||||||
|
|
||||||
|
// Throttle settings: update DB at most every 100ms
|
||||||
|
const UPDATE_INTERVAL_MS: u128 = 100;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check for cancellation. If we already have headers/body, just close cleanly without error
|
// Check for cancellation. If we already have headers/body, just close cleanly without error
|
||||||
if *cancelled_rx.borrow() {
|
if *cancelled_rx.borrow() {
|
||||||
@@ -401,11 +521,17 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
.map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?;
|
.map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?;
|
||||||
written_bytes += n;
|
written_bytes += n;
|
||||||
|
|
||||||
// Update response in DB with progress
|
// Throttle DB updates: only update if enough time has passed
|
||||||
let mut r = response.lock().await;
|
let now = Instant::now();
|
||||||
r.elapsed = start.elapsed().as_millis() as i32; // Approx until the end
|
let elapsed_since_update = now.duration_since(last_update_time).as_millis();
|
||||||
r.content_length = Some(written_bytes as i32);
|
|
||||||
app_handle.db().update_http_response_if_id(&r, &update_source)?;
|
if elapsed_since_update >= UPDATE_INTERVAL_MS {
|
||||||
|
response_ctx.update(|r| {
|
||||||
|
r.elapsed = start.elapsed().as_millis() as i32;
|
||||||
|
r.content_length = Some(written_bytes as i64);
|
||||||
|
})?;
|
||||||
|
last_update_time = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(GenericError(format!("Failed to read response body: {}", e)));
|
return Err(GenericError(format!("Failed to read response body: {}", e)));
|
||||||
@@ -413,17 +539,108 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final update with closed state
|
// Final update with closed state and accurate byte count
|
||||||
let mut resp = response.lock().await.clone();
|
response_ctx.update(|r| {
|
||||||
resp.elapsed = start.elapsed().as_millis() as i32;
|
r.elapsed = start.elapsed().as_millis() as i32;
|
||||||
resp.state = HttpResponseState::Closed;
|
r.content_length = Some(written_bytes as i64);
|
||||||
resp.body_path = Some(
|
r.state = HttpResponseState::Closed;
|
||||||
body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(),
|
})?;
|
||||||
);
|
|
||||||
|
|
||||||
app_handle.db().update_http_response_if_id(&resp, &update_source)?;
|
Ok((response_ctx.response().clone(), maybe_blob_write_handle))
|
||||||
|
}
|
||||||
|
|
||||||
Ok(resp)
|
fn write_bytes_to_db_sync<R: Runtime>(
|
||||||
|
response_ctx: &mut ResponseContext<R>,
|
||||||
|
body_id: &str,
|
||||||
|
data: Bytes,
|
||||||
|
) -> Result<()> {
|
||||||
|
if data.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write in chunks if data is large
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut chunk_index = 0;
|
||||||
|
while offset < data.len() {
|
||||||
|
let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, data.len());
|
||||||
|
let chunk_data = data.slice(offset..end).to_vec();
|
||||||
|
let chunk = BodyChunk::new(body_id, chunk_index, chunk_data);
|
||||||
|
response_ctx.app_handle.blobs().insert_chunk(&chunk)?;
|
||||||
|
offset = end;
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the response with the total request body size
|
||||||
|
response_ctx.update(|r| {
|
||||||
|
r.request_content_length = Some(data.len() as i64);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_stream_chunks_to_db<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
body_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
response_id: &str,
|
||||||
|
update_source: &UpdateSource,
|
||||||
|
mut rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut buffer = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE);
|
||||||
|
let mut chunk_index = 0;
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
|
||||||
|
while let Some(data) = rx.recv().await {
|
||||||
|
total_bytes += data.len();
|
||||||
|
buffer.extend_from_slice(&data);
|
||||||
|
|
||||||
|
// Flush when buffer reaches chunk size
|
||||||
|
while buffer.len() >= REQUEST_BODY_CHUNK_SIZE {
|
||||||
|
debug!("Writing chunk {chunk_index} to DB");
|
||||||
|
let chunk_data: Vec<u8> = buffer.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
|
||||||
|
let chunk = BodyChunk::new(body_id, chunk_index, chunk_data);
|
||||||
|
app_handle.blobs().insert_chunk(&chunk)?;
|
||||||
|
app_handle.db().upsert_http_response_event(
|
||||||
|
&HttpResponseEvent::new(
|
||||||
|
response_id,
|
||||||
|
workspace_id,
|
||||||
|
yaak_http::sender::HttpResponseEvent::ChunkSent {
|
||||||
|
bytes: REQUEST_BODY_CHUNK_SIZE,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
update_source,
|
||||||
|
)?;
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining data
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
let chunk = BodyChunk::new(body_id, chunk_index, buffer);
|
||||||
|
debug!("Flushing remaining data {chunk_index} {}", chunk.data.len());
|
||||||
|
app_handle.blobs().insert_chunk(&chunk)?;
|
||||||
|
app_handle.db().upsert_http_response_event(
|
||||||
|
&HttpResponseEvent::new(
|
||||||
|
response_id,
|
||||||
|
workspace_id,
|
||||||
|
yaak_http::sender::HttpResponseEvent::ChunkSent { bytes: chunk.data.len() }.into(),
|
||||||
|
),
|
||||||
|
update_source,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the response with the total request body size
|
||||||
|
app_handle.with_tx(|tx| {
|
||||||
|
debug!("Updating final body length {total_bytes}");
|
||||||
|
if let Ok(mut response) = tx.get_http_response(&response_id) {
|
||||||
|
response.request_content_length = Some(total_bytes as i64);
|
||||||
|
tx.update_http_response_if_id(&response, update_source)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_authentication<R: Runtime>(
|
async fn apply_authentication<R: Runtime>(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use yaak_common::window::WorkspaceWindowTrait;
|
|||||||
use yaak_grpc::manager::GrpcHandle;
|
use yaak_grpc::manager::GrpcHandle;
|
||||||
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||||
use yaak_mac_window::AppHandleMacWindowExt;
|
use yaak_mac_window::AppHandleMacWindowExt;
|
||||||
|
use yaak_models::blob_manager::BlobManagerExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
||||||
@@ -784,7 +785,7 @@ async fn cmd_http_response_body<R: Runtime>(
|
|||||||
) -> YaakResult<FilterResponse> {
|
) -> YaakResult<FilterResponse> {
|
||||||
let body_path = match response.body_path {
|
let body_path = match response.body_path {
|
||||||
None => {
|
None => {
|
||||||
return Err(GenericError("Response body path not set".to_string()));
|
return Ok(FilterResponse { content: String::new(), error: None });
|
||||||
}
|
}
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
};
|
};
|
||||||
@@ -809,6 +810,23 @@ async fn cmd_http_response_body<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cmd_http_request_body<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
response_id: &str,
|
||||||
|
) -> YaakResult<Option<Vec<u8>>> {
|
||||||
|
let body_id = format!("{}.request", response_id);
|
||||||
|
let chunks = app_handle.blobs().get_chunks(&body_id)?;
|
||||||
|
|
||||||
|
if chunks.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate all chunks
|
||||||
|
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||||
|
Ok(Some(body))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
|
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
|
||||||
let body = fs::read(file_path)?;
|
let body = fs::read(file_path)?;
|
||||||
@@ -835,9 +853,7 @@ async fn cmd_get_http_response_events<R: Runtime>(
|
|||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
response_id: &str,
|
response_id: &str,
|
||||||
) -> YaakResult<Vec<HttpResponseEvent>> {
|
) -> YaakResult<Vec<HttpResponseEvent>> {
|
||||||
use yaak_models::models::HttpResponseEventIden;
|
let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;
|
||||||
let events: Vec<HttpResponseEvent> =
|
|
||||||
app_handle.db().find_many(HttpResponseEventIden::ResponseId, response_id, None)?;
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,6 +1131,7 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
// that has not yet been saved in the DB.
|
// that has not yet been saved in the DB.
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
) -> YaakResult<HttpResponse> {
|
) -> YaakResult<HttpResponse> {
|
||||||
|
let blobs = app_handle.blob_manager();
|
||||||
let response = app_handle.db().upsert_http_response(
|
let response = app_handle.db().upsert_http_response(
|
||||||
&HttpResponse {
|
&HttpResponse {
|
||||||
request_id: request.id.clone(),
|
request_id: request.id.clone(),
|
||||||
@@ -1122,6 +1139,7 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::from_window(&window),
|
&UpdateSource::from_window(&window),
|
||||||
|
&blobs,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||||
@@ -1167,6 +1185,7 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
..resp
|
..resp
|
||||||
},
|
},
|
||||||
&UpdateSource::from_window(&window),
|
&UpdateSource::from_window(&window),
|
||||||
|
&blobs,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1174,23 +1193,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn response_err<R: Runtime>(
|
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
response: &HttpResponse,
|
|
||||||
error: String,
|
|
||||||
update_source: &UpdateSource,
|
|
||||||
) -> HttpResponse {
|
|
||||||
warn!("Failed to send request: {error:?}");
|
|
||||||
let mut response = response.clone();
|
|
||||||
response.state = HttpResponseState::Closed;
|
|
||||||
response.error = Some(error.clone());
|
|
||||||
response = app_handle
|
|
||||||
.db()
|
|
||||||
.update_http_response_if_id(&response, update_source)
|
|
||||||
.expect("Failed to update response");
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_install_plugin<R: Runtime>(
|
async fn cmd_install_plugin<R: Runtime>(
|
||||||
directory: &str,
|
directory: &str,
|
||||||
@@ -1468,6 +1470,7 @@ pub fn run() {
|
|||||||
cmd_delete_send_history,
|
cmd_delete_send_history,
|
||||||
cmd_dismiss_notification,
|
cmd_dismiss_notification,
|
||||||
cmd_export_data,
|
cmd_export_data,
|
||||||
|
cmd_http_request_body,
|
||||||
cmd_http_response_body,
|
cmd_http_response_body,
|
||||||
cmd_format_json,
|
cmd_format_json,
|
||||||
cmd_get_http_authentication_summaries,
|
cmd_get_http_authentication_summaries,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use log::error;
|
|||||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
use yaak_common::window::WorkspaceWindowTrait;
|
use yaak_common::window::WorkspaceWindowTrait;
|
||||||
|
use yaak_models::blob_manager::BlobManagerExt;
|
||||||
use yaak_models::models::{HttpResponse, Plugin};
|
use yaak_models::models::{HttpResponse, Plugin};
|
||||||
use yaak_models::queries::any_request::AnyRequest;
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
use yaak_models::query_manager::QueryManagerExt;
|
use yaak_models::query_manager::QueryManagerExt;
|
||||||
@@ -194,6 +195,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
let http_response = if http_request.id.is_empty() {
|
let http_response = if http_request.id.is_empty() {
|
||||||
HttpResponse::default()
|
HttpResponse::default()
|
||||||
} else {
|
} else {
|
||||||
|
let blobs = window.blob_manager();
|
||||||
window.db().upsert_http_response(
|
window.db().upsert_http_response(
|
||||||
&HttpResponse {
|
&HttpResponse {
|
||||||
request_id: http_request.id.clone(),
|
request_id: http_request.id.clone(),
|
||||||
@@ -201,6 +203,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::Plugin,
|
&UpdateSource::Plugin,
|
||||||
|
&blobs,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod manager;
|
|||||||
pub mod path_placeholders;
|
pub mod path_placeholders;
|
||||||
mod proto;
|
mod proto;
|
||||||
pub mod sender;
|
pub mod sender;
|
||||||
|
pub mod tee_reader;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ pub struct BodyStats {
|
|||||||
/// An AsyncRead wrapper that sends chunk events as data is read
|
/// An AsyncRead wrapper that sends chunk events as data is read
|
||||||
pub struct TrackingRead<R> {
|
pub struct TrackingRead<R> {
|
||||||
inner: R,
|
inner: R,
|
||||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
ended: bool,
|
ended: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> TrackingRead<R> {
|
impl<R> TrackingRead<R> {
|
||||||
pub fn new(inner: R, event_tx: mpsc::UnboundedSender<HttpResponseEvent>) -> Self {
|
pub fn new(inner: R, event_tx: mpsc::Sender<HttpResponseEvent>) -> Self {
|
||||||
Self { inner, event_tx, ended: false }
|
Self { inner, event_tx, ended: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,8 +131,9 @@ impl<R: AsyncRead + Unpin> AsyncRead for TrackingRead<R> {
|
|||||||
if let Poll::Ready(Ok(())) = &result {
|
if let Poll::Ready(Ok(())) = &result {
|
||||||
let bytes_read = buf.filled().len() - before;
|
let bytes_read = buf.filled().len() - before;
|
||||||
if bytes_read > 0 {
|
if bytes_read > 0 {
|
||||||
// Ignore send errors - receiver may have been dropped
|
// Ignore send errors - receiver may have been dropped or channel is full
|
||||||
let _ = self.event_tx.send(HttpResponseEvent::ChunkReceived { bytes: bytes_read });
|
let _ =
|
||||||
|
self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read });
|
||||||
} else if !self.ended {
|
} else if !self.ended {
|
||||||
self.ended = true;
|
self.ended = true;
|
||||||
}
|
}
|
||||||
@@ -311,7 +312,7 @@ pub trait HttpSender: Send + Sync {
|
|||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse>;
|
) -> Result<HttpResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,11 +339,11 @@ impl HttpSender for ReqwestSender {
|
|||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
// Helper to send events (ignores errors if receiver is dropped)
|
// Helper to send events (ignores errors if receiver is dropped or channel is full)
|
||||||
let send_event = |event: HttpResponseEvent| {
|
let send_event = |event: HttpResponseEvent| {
|
||||||
let _ = event_tx.send(event);
|
let _ = event_tx.try_send(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse the HTTP method
|
// Parse the HTTP method
|
||||||
|
|||||||
171
src-tauri/yaak-http/src/tee_reader.rs
Normal file
171
src-tauri/yaak-http/src/tee_reader.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use tokio::io::{AsyncRead, ReadBuf};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// A reader that forwards all read data to a channel while also returning it to the caller.
|
||||||
|
/// This allows capturing request body data as it's being sent.
|
||||||
|
/// Uses a bounded channel to provide backpressure if the receiver is slow.
|
||||||
|
pub struct TeeReader<R> {
|
||||||
|
inner: R,
|
||||||
|
tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> TeeReader<R> {
|
||||||
|
pub fn new(inner: R, tx: mpsc::Sender<Vec<u8>>) -> Self {
|
||||||
|
Self { inner, tx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: AsyncRead + Unpin> AsyncRead for TeeReader<R> {
|
||||||
|
fn poll_read(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<io::Result<()>> {
|
||||||
|
let before_len = buf.filled().len();
|
||||||
|
|
||||||
|
match Pin::new(&mut self.inner).poll_read(cx, buf) {
|
||||||
|
Poll::Ready(Ok(())) => {
|
||||||
|
let after_len = buf.filled().len();
|
||||||
|
if after_len > before_len {
|
||||||
|
// Data was read, send a copy to the channel
|
||||||
|
let data = buf.filled()[before_len..after_len].to_vec();
|
||||||
|
// Use try_send to avoid blocking. If channel is full, we drop the data
|
||||||
|
// rather than blocking the HTTP request. This provides backpressure
|
||||||
|
// by slowing down the reader when the DB writer can't keep up.
|
||||||
|
match self.tx.try_send(data) {
|
||||||
|
Ok(_) => {} // Successfully sent
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
|
// Channel is full - apply backpressure by returning Pending
|
||||||
|
// This will cause the reader to be polled again later
|
||||||
|
cx.waker().wake_by_ref();
|
||||||
|
return Poll::Pending;
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
|
// Receiver dropped - continue without capturing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tee_reader_captures_all_data() {
|
||||||
|
let data = b"Hello, World!";
|
||||||
|
let cursor = Cursor::new(data.to_vec());
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
let mut tee = TeeReader::new(cursor, tx);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
tee.read_to_end(&mut output).await.unwrap();
|
||||||
|
|
||||||
|
// Verify the reader returns the correct data
|
||||||
|
assert_eq!(output, data);
|
||||||
|
|
||||||
|
// Verify the channel received the data
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
while let Ok(chunk) = rx.try_recv() {
|
||||||
|
captured.extend(chunk);
|
||||||
|
}
|
||||||
|
assert_eq!(captured, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tee_reader_with_chunked_reads() {
|
||||||
|
let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
let cursor = Cursor::new(data.to_vec());
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
let mut tee = TeeReader::new(cursor, tx);
|
||||||
|
|
||||||
|
// Read in small chunks
|
||||||
|
let mut buf = [0u8; 5];
|
||||||
|
let mut output = Vec::new();
|
||||||
|
loop {
|
||||||
|
let n = tee.read(&mut buf).await.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
output.extend_from_slice(&buf[..n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the reader returns the correct data
|
||||||
|
assert_eq!(output, data);
|
||||||
|
|
||||||
|
// Verify the channel received all chunks
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
while let Ok(chunk) = rx.try_recv() {
|
||||||
|
captured.extend(chunk);
|
||||||
|
}
|
||||||
|
assert_eq!(captured, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tee_reader_empty_data() {
|
||||||
|
let data: Vec<u8> = vec![];
|
||||||
|
let cursor = Cursor::new(data.clone());
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
let mut tee = TeeReader::new(cursor, tx);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
tee.read_to_end(&mut output).await.unwrap();
|
||||||
|
|
||||||
|
// Verify empty output
|
||||||
|
assert!(output.is_empty());
|
||||||
|
|
||||||
|
// Verify no data was sent to channel
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tee_reader_works_when_receiver_dropped() {
|
||||||
|
let data = b"Hello, World!";
|
||||||
|
let cursor = Cursor::new(data.to_vec());
|
||||||
|
let (tx, rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
// Drop the receiver before reading
|
||||||
|
drop(rx);
|
||||||
|
|
||||||
|
let mut tee = TeeReader::new(cursor, tx);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
|
||||||
|
// Should still work even though receiver is dropped
|
||||||
|
tee.read_to_end(&mut output).await.unwrap();
|
||||||
|
assert_eq!(output, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tee_reader_large_data() {
|
||||||
|
// Test with 1MB of data
|
||||||
|
let data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||||
|
let cursor = Cursor::new(data.clone());
|
||||||
|
let (tx, mut rx) = mpsc::channel(100);
|
||||||
|
|
||||||
|
let mut tee = TeeReader::new(cursor, tx);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
tee.read_to_end(&mut output).await.unwrap();
|
||||||
|
|
||||||
|
// Verify the reader returns the correct data
|
||||||
|
assert_eq!(output, data);
|
||||||
|
|
||||||
|
// Verify the channel received all data
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
while let Ok(chunk) = rx.try_recv() {
|
||||||
|
captured.extend(chunk);
|
||||||
|
}
|
||||||
|
assert_eq!(captured, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
mut cancelled_rx: Receiver<bool>,
|
mut cancelled_rx: Receiver<bool>,
|
||||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let mut redirect_count = 0;
|
let mut redirect_count = 0;
|
||||||
let mut current_url = request.url;
|
let mut current_url = request.url;
|
||||||
@@ -36,9 +36,9 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
let mut current_headers = request.headers;
|
let mut current_headers = request.headers;
|
||||||
let mut current_body = request.body;
|
let mut current_body = request.body;
|
||||||
|
|
||||||
// Helper to send events (ignores errors if receiver is dropped)
|
// Helper to send events (ignores errors if receiver is dropped or channel is full)
|
||||||
let send_event = |event: HttpResponseEvent| {
|
let send_event = |event: HttpResponseEvent| {
|
||||||
let _ = event_tx.send(event);
|
let _ = event_tx.try_send(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -236,7 +236,7 @@ mod tests {
|
|||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
_request: SendableHttpRequest,
|
_request: SendableHttpRequest,
|
||||||
_event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let mut responses = self.responses.lock().await;
|
let mut responses = self.responses.lock().await;
|
||||||
if responses.is_empty() {
|
if responses.is_empty() {
|
||||||
@@ -276,7 +276,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||||
assert_eq!(result.status, 200);
|
assert_eq!(result.status, 200);
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||||
assert_eq!(result.status, 200);
|
assert_eq!(result.status, 200);
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||||
if let Err(crate::error::Error::RequestError(msg)) = result {
|
if let Err(crate::error::Error::RequestError(msg)) = result {
|
||||||
assert!(msg.contains("Maximum redirect limit"));
|
assert!(msg.contains("Maximum redirect limit"));
|
||||||
|
|||||||
4
src-tauri/yaak-models/bindings/gen_models.ts
generated
4
src-tauri/yaak-models/bindings/gen_models.ts
generated
@@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
|||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "start_request" } | { "type": "end_request" } | { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE body_chunks
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
body_id TEXT NOT NULL,
|
||||||
|
chunk_index INTEGER NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE (body_id, chunk_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE http_responses
|
||||||
|
ADD COLUMN request_content_length INTEGER;
|
||||||
372
src-tauri/yaak-models/src/blob_manager.rs
Normal file
372
src-tauri/yaak-models/src/blob_manager.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::util::generate_prefixed_id;
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
|
use log::{debug, info};
|
||||||
|
use r2d2::Pool;
|
||||||
|
use r2d2_sqlite::SqliteConnectionManager;
|
||||||
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{Manager, Runtime, State};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension trait for accessing the blob manager from app handle.
|
||||||
|
pub trait BlobManagerExt<'a, R> {
|
||||||
|
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||||
|
fn blobs(&'a self) -> BlobContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||||
|
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||||
|
self.state::<BlobManager>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blobs(&'a self) -> BlobContext {
|
||||||
|
let manager = self.state::<BlobManager>();
|
||||||
|
manager.inner().connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the blob database connection pool.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BlobManager {
|
||||||
|
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobManager {
|
||||||
|
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
|
||||||
|
Self { pool: Arc::new(Mutex::new(pool)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect(&self) -> BlobContext {
|
||||||
|
let conn = self
|
||||||
|
.pool
|
||||||
|
.lock()
|
||||||
|
.expect("Failed to gain lock on blob DB")
|
||||||
|
.get()
|
||||||
|
.expect("Failed to get blob DB connection from pool");
|
||||||
|
BlobContext { conn }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context for blob database operations.
|
||||||
|
pub struct BlobContext {
|
||||||
|
conn: r2d2::PooledConnection<SqliteConnectionManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobContext {
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total size of a body without loading data.
|
||||||
|
impl BlobContext {
|
||||||
|
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
|
||||||
|
let size: i64 = self
|
||||||
|
.conn
|
||||||
|
.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
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
|
||||||
|
params![body_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run migrations for the blob database.
|
||||||
|
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> 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::*;
|
||||||
|
|
||||||
|
fn create_test_pool() -> Pool<SqliteConnectionManager> {
|
||||||
|
let manager = SqliteConnectionManager::memory();
|
||||||
|
let pool = Pool::builder().max_size(1).build(manager).unwrap();
|
||||||
|
migrate_blob_db(&pool).unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_and_get_chunks() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
let body_id = "rs_test123.request";
|
||||||
|
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
|
||||||
|
let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec());
|
||||||
|
|
||||||
|
ctx.insert_chunk(&chunk1).unwrap();
|
||||||
|
ctx.insert_chunk(&chunk2).unwrap();
|
||||||
|
|
||||||
|
let chunks = ctx.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 pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
let body_id = "rs_test123.request";
|
||||||
|
|
||||||
|
// Insert out of order
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap();
|
||||||
|
|
||||||
|
let chunks = ctx.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 pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
let body_id = "rs_test123.request";
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
|
||||||
|
|
||||||
|
assert!(ctx.body_exists(body_id).unwrap());
|
||||||
|
|
||||||
|
ctx.delete_chunks(body_id).unwrap();
|
||||||
|
|
||||||
|
assert!(!ctx.body_exists(body_id).unwrap());
|
||||||
|
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_chunks_like() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
// Insert chunks for same response but different body types
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap();
|
||||||
|
|
||||||
|
// Delete all bodies for rs_abc
|
||||||
|
ctx.delete_chunks_like("rs_abc.%").unwrap();
|
||||||
|
|
||||||
|
// rs_abc bodies should be gone
|
||||||
|
assert!(!ctx.body_exists("rs_abc.request").unwrap());
|
||||||
|
assert!(!ctx.body_exists("rs_abc.response").unwrap());
|
||||||
|
|
||||||
|
// rs_other should still exist
|
||||||
|
assert!(ctx.body_exists("rs_other.request").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_body_size() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
let body_id = "rs_test123.request";
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap();
|
||||||
|
|
||||||
|
let size = ctx.get_body_size(body_id).unwrap();
|
||||||
|
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_body_size_empty() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
let size = ctx.get_body_size("nonexistent").unwrap();
|
||||||
|
assert_eq!(size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_body_exists() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
assert!(!ctx.body_exists("rs_test.request").unwrap());
|
||||||
|
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap();
|
||||||
|
|
||||||
|
assert!(ctx.body_exists("rs_test.request").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_bodies_isolated() {
|
||||||
|
let pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
|
||||||
|
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
|
||||||
|
|
||||||
|
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 pool = create_test_pool();
|
||||||
|
let manager = BlobManager::new(pool);
|
||||||
|
let ctx = manager.connect();
|
||||||
|
|
||||||
|
// 1MB chunk
|
||||||
|
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||||
|
let body_id = "rs_large.request";
|
||||||
|
|
||||||
|
ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::blob_manager::BlobManagerExt;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
use crate::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||||
@@ -8,6 +9,7 @@ use tauri::{AppHandle, Runtime, WebviewWindow};
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn upsert<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> Result<String> {
|
pub(crate) fn upsert<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> Result<String> {
|
||||||
let db = window.db();
|
let db = window.db();
|
||||||
|
let blobs = window.blob_manager();
|
||||||
let source = &UpdateSource::from_window(&window);
|
let source = &UpdateSource::from_window(&window);
|
||||||
let id = match model {
|
let id = match model {
|
||||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||||
@@ -15,7 +17,7 @@ pub(crate) fn upsert<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> R
|
|||||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&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::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||||
@@ -30,6 +32,7 @@ pub(crate) fn upsert<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> R
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn delete<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> Result<String> {
|
pub(crate) fn delete<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> Result<String> {
|
||||||
|
let blobs = window.blob_manager();
|
||||||
// Use transaction for deletions because it might recurse
|
// Use transaction for deletions because it might recurse
|
||||||
window.with_tx(|tx| {
|
window.with_tx(|tx| {
|
||||||
let source = &UpdateSource::from_window(&window);
|
let source = &UpdateSource::from_window(&window);
|
||||||
@@ -40,7 +43,7 @@ pub(crate) fn delete<R: Runtime>(window: WebviewWindow<R>, model: AnyModel) -> R
|
|||||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source)?.id,
|
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ impl<'a> DbContext<'a> {
|
|||||||
.expect("Failed to run find on DB")
|
.expect("Failed to run find on DB")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_all<'s, M>(&self) -> Result<Vec<M>>
|
pub(crate) fn find_all<'s, M>(&self) -> Result<Vec<M>>
|
||||||
where
|
where
|
||||||
M: Into<AnyModel> + Clone + UpsertModelInfo,
|
M: Into<AnyModel> + Clone + UpsertModelInfo,
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@ impl<'a> DbContext<'a> {
|
|||||||
Ok(items.map(|v| v.unwrap()).collect())
|
Ok(items.map(|v| v.unwrap()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_many<'s, M>(
|
pub(crate) fn find_many<'s, M>(
|
||||||
&self,
|
&self,
|
||||||
col: impl IntoColumnRef,
|
col: impl IntoColumnRef,
|
||||||
value: impl Into<SimpleExpr>,
|
value: impl Into<SimpleExpr>,
|
||||||
@@ -115,7 +115,7 @@ impl<'a> DbContext<'a> {
|
|||||||
Ok(items.map(|v| v.unwrap()).collect())
|
Ok(items.map(|v| v.unwrap()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
|
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
|
||||||
where
|
where
|
||||||
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
|
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::blob_manager::{BlobManager, migrate_blob_db};
|
||||||
use crate::commands::*;
|
use crate::commands::*;
|
||||||
use crate::migrate::migrate_db;
|
use crate::migrate::migrate_db;
|
||||||
use crate::query_manager::QueryManager;
|
use crate::query_manager::QueryManager;
|
||||||
@@ -14,6 +15,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
|
pub mod blob_manager;
|
||||||
mod connection_or_tx;
|
mod connection_or_tx;
|
||||||
pub mod db_context;
|
pub mod db_context;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -50,7 +52,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
create_dir_all(app_path.clone()).expect("Problem creating App directory!");
|
create_dir_all(app_path.clone()).expect("Problem creating App directory!");
|
||||||
|
|
||||||
let db_file_path = app_path.join("db.sqlite");
|
let db_file_path = app_path.join("db.sqlite");
|
||||||
|
let blob_db_file_path = app_path.join("blobs.sqlite");
|
||||||
|
|
||||||
|
// Main database pool
|
||||||
let manager = SqliteConnectionManager::file(db_file_path);
|
let manager = SqliteConnectionManager::file(db_file_path);
|
||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(100) // Up from 10 (just in case)
|
.max_size(100) // Up from 10 (just in case)
|
||||||
@@ -68,7 +72,26 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
return Err(Box::from(e.to_string()));
|
return Err(Box::from(e.to_string()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Blob database pool
|
||||||
|
let blob_manager = SqliteConnectionManager::file(blob_db_file_path);
|
||||||
|
let blob_pool = Pool::builder()
|
||||||
|
.max_size(50)
|
||||||
|
.connection_timeout(Duration::from_secs(10))
|
||||||
|
.build(blob_manager)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if let Err(e) = migrate_blob_db(&blob_pool) {
|
||||||
|
error!("Failed to run blob database migration {e:?}");
|
||||||
|
app_handle
|
||||||
|
.dialog()
|
||||||
|
.message(e.to_string())
|
||||||
|
.kind(MessageDialogKind::Error)
|
||||||
|
.blocking_show();
|
||||||
|
return Err(Box::from(e.to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
app_handle.manage(SqliteConnection::new(pool.clone()));
|
app_handle.manage(SqliteConnection::new(pool.clone()));
|
||||||
|
app_handle.manage(BlobManager::new(blob_pool));
|
||||||
|
|
||||||
{
|
{
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|||||||
@@ -1322,13 +1322,14 @@ pub struct HttpResponse {
|
|||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
|
|
||||||
pub body_path: Option<String>,
|
pub body_path: Option<String>,
|
||||||
pub content_length: Option<i32>,
|
pub content_length: Option<i64>,
|
||||||
pub content_length_compressed: Option<i32>,
|
pub content_length_compressed: Option<i64>,
|
||||||
pub elapsed: i32,
|
pub elapsed: i32,
|
||||||
pub elapsed_headers: i32,
|
pub elapsed_headers: i32,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub headers: Vec<HttpResponseHeader>,
|
pub headers: Vec<HttpResponseHeader>,
|
||||||
pub remote_addr: Option<String>,
|
pub remote_addr: Option<String>,
|
||||||
|
pub request_content_length: Option<i64>,
|
||||||
pub request_headers: Vec<HttpResponseHeader>,
|
pub request_headers: Vec<HttpResponseHeader>,
|
||||||
pub status: i32,
|
pub status: i32,
|
||||||
pub status_reason: Option<String>,
|
pub status_reason: Option<String>,
|
||||||
@@ -1382,6 +1383,7 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
(StatusReason, self.status_reason.into()),
|
(StatusReason, self.status_reason.into()),
|
||||||
(Url, self.url.into()),
|
(Url, self.url.into()),
|
||||||
(Version, self.version.into()),
|
(Version, self.version.into()),
|
||||||
|
(RequestContentLength, self.request_content_length.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,6 +1398,7 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
HttpResponseIden::Error,
|
HttpResponseIden::Error,
|
||||||
HttpResponseIden::Headers,
|
HttpResponseIden::Headers,
|
||||||
HttpResponseIden::RemoteAddr,
|
HttpResponseIden::RemoteAddr,
|
||||||
|
HttpResponseIden::RequestContentLength,
|
||||||
HttpResponseIden::RequestHeaders,
|
HttpResponseIden::RequestHeaders,
|
||||||
HttpResponseIden::State,
|
HttpResponseIden::State,
|
||||||
HttpResponseIden::Status,
|
HttpResponseIden::Status,
|
||||||
@@ -1431,6 +1434,7 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||||
body_path: r.get("body_path")?,
|
body_path: r.get("body_path")?,
|
||||||
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
||||||
|
request_content_length: r.get("request_content_length").unwrap_or_default(),
|
||||||
request_headers: serde_json::from_str(
|
request_headers: serde_json::from_str(
|
||||||
r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
|
r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
|
||||||
)
|
)
|
||||||
|
|||||||
18
src-tauri/yaak-models/src/queries/http_response_events.rs
Normal file
18
src-tauri/yaak-models/src/queries/http_response_events.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use crate::db_context::DbContext;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
|
||||||
|
use crate::util::UpdateSource;
|
||||||
|
|
||||||
|
impl<'a> DbContext<'a> {
|
||||||
|
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
|
||||||
|
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_http_response_event(
|
||||||
|
&self,
|
||||||
|
http_response_event: &HttpResponseEvent,
|
||||||
|
source: &UpdateSource,
|
||||||
|
) -> Result<HttpResponseEvent> {
|
||||||
|
self.upsert(http_response_event, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::blob_manager::BlobManager;
|
||||||
use crate::db_context::DbContext;
|
use crate::db_context::DbContext;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
||||||
@@ -58,6 +59,7 @@ impl<'a> DbContext<'a> {
|
|||||||
&self,
|
&self,
|
||||||
http_response: &HttpResponse,
|
http_response: &HttpResponse,
|
||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
|
blob_manager: &BlobManager,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
// Delete the body file if it exists
|
// Delete the body file if it exists
|
||||||
if let Some(p) = http_response.body_path.clone() {
|
if let Some(p) = http_response.body_path.clone() {
|
||||||
@@ -66,6 +68,13 @@ impl<'a> DbContext<'a> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete request body blobs (pattern: {response_id}.request)
|
||||||
|
let blob_ctx = blob_manager.connect();
|
||||||
|
let body_id = format!("{}.request", http_response.id);
|
||||||
|
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
|
||||||
|
error!("Failed to delete request body blobs: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(self.delete(http_response, source)?)
|
Ok(self.delete(http_response, source)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +82,13 @@ impl<'a> DbContext<'a> {
|
|||||||
&self,
|
&self,
|
||||||
http_response: &HttpResponse,
|
http_response: &HttpResponse,
|
||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
|
blob_manager: &BlobManager,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let responses = self.list_http_responses_for_request(&http_response.request_id, None)?;
|
let responses = self.list_http_responses_for_request(&http_response.request_id, None)?;
|
||||||
|
|
||||||
for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) {
|
for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) {
|
||||||
debug!("Deleting old HTTP response {}", http_response.id);
|
debug!("Deleting old HTTP response {}", http_response.id);
|
||||||
self.delete_http_response(&m, source)?;
|
self.delete_http_response(&m, source, blob_manager)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.upsert(http_response, source)
|
self.upsert(http_response, source)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod grpc_connections;
|
|||||||
mod grpc_events;
|
mod grpc_events;
|
||||||
mod grpc_requests;
|
mod grpc_requests;
|
||||||
mod http_requests;
|
mod http_requests;
|
||||||
|
mod http_response_events;
|
||||||
mod http_responses;
|
mod http_responses;
|
||||||
mod key_values;
|
mod key_values;
|
||||||
mod plugin_key_values;
|
mod plugin_key_values;
|
||||||
|
|||||||
2
src-tauri/yaak-plugins/bindings/gen_models.ts
generated
2
src-tauri/yaak-plugins/bindings/gen_models.ts
generated
@@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
2
src-tauri/yaak-templates/pkg/yaak_templates.d.ts
generated
vendored
2
src-tauri/yaak-templates/pkg/yaak_templates.d.ts
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export function unescape_template(template: string): any;
|
export function unescape_template(template: string): any;
|
||||||
export function parse_template(template: string): any;
|
|
||||||
export function escape_template(template: string): any;
|
export function escape_template(template: string): any;
|
||||||
|
export function parse_template(template: string): any;
|
||||||
|
|||||||
3
src-tauri/yaak-templates/pkg/yaak_templates.js
generated
3
src-tauri/yaak-templates/pkg/yaak_templates.js
generated
@@ -1,4 +1,5 @@
|
|||||||
import * as wasm from "./yaak_templates_bg.wasm";
|
import * as wasm from "./yaak_templates_bg.wasm";
|
||||||
export * from "./yaak_templates_bg.js";
|
export * from "./yaak_templates_bg.js";
|
||||||
import { __wbg_set_wasm } from "./yaak_templates_bg.js";
|
import { __wbg_set_wasm } from "./yaak_templates_bg.js";
|
||||||
__wbg_set_wasm(wasm);
|
__wbg_set_wasm(wasm);
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
|||||||
141
src-tauri/yaak-templates/pkg/yaak_templates_bg.js
generated
141
src-tauri/yaak-templates/pkg/yaak_templates_bg.js
generated
@@ -4,35 +4,6 @@ export function __wbg_set_wasm(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const heap = new Array(128).fill(undefined);
|
|
||||||
|
|
||||||
heap.push(undefined, null, true, false);
|
|
||||||
|
|
||||||
let heap_next = heap.length;
|
|
||||||
|
|
||||||
function addHeapObject(obj) {
|
|
||||||
if (heap_next === heap.length) heap.push(heap.length + 1);
|
|
||||||
const idx = heap_next;
|
|
||||||
heap_next = heap[idx];
|
|
||||||
|
|
||||||
heap[idx] = obj;
|
|
||||||
return idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getObject(idx) { return heap[idx]; }
|
|
||||||
|
|
||||||
function dropObject(idx) {
|
|
||||||
if (idx < 132) return;
|
|
||||||
heap[idx] = heap_next;
|
|
||||||
heap_next = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeObject(idx) {
|
|
||||||
const ret = getObject(idx);
|
|
||||||
dropObject(idx);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugString(val) {
|
function debugString(val) {
|
||||||
// primitive types
|
// primitive types
|
||||||
const type = typeof val;
|
const type = typeof val;
|
||||||
@@ -184,48 +155,24 @@ function getStringFromWasm0(ptr, len) {
|
|||||||
ptr = ptr >>> 0;
|
ptr = ptr >>> 0;
|
||||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_export_2.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* @param {string} template
|
* @param {string} template
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function unescape_template(template) {
|
export function unescape_template(template) {
|
||||||
try {
|
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
const len0 = WASM_VECTOR_LEN;
|
||||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
|
const ret = wasm.unescape_template(ptr0, len0);
|
||||||
const len0 = WASM_VECTOR_LEN;
|
if (ret[2]) {
|
||||||
wasm.unescape_template(retptr, ptr0, len0);
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
||||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
||||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
||||||
if (r2) {
|
|
||||||
throw takeObject(r1);
|
|
||||||
}
|
|
||||||
return takeObject(r0);
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} template
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
export function parse_template(template) {
|
|
||||||
try {
|
|
||||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
||||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
wasm.parse_template(retptr, ptr0, len0);
|
|
||||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
||||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
||||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
||||||
if (r2) {
|
|
||||||
throw takeObject(r1);
|
|
||||||
}
|
|
||||||
return takeObject(r0);
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
||||||
}
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,61 +180,69 @@ export function parse_template(template) {
|
|||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function escape_template(template) {
|
export function escape_template(template) {
|
||||||
try {
|
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
const len0 = WASM_VECTOR_LEN;
|
||||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
|
const ret = wasm.escape_template(ptr0, len0);
|
||||||
const len0 = WASM_VECTOR_LEN;
|
if (ret[2]) {
|
||||||
wasm.escape_template(retptr, ptr0, len0);
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
||||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
||||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
||||||
if (r2) {
|
|
||||||
throw takeObject(r1);
|
|
||||||
}
|
|
||||||
return takeObject(r0);
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
||||||
}
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} template
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function parse_template(template) {
|
||||||
|
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.parse_template(ptr0, len0);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function __wbg_new_405e22f390576ce2() {
|
export function __wbg_new_405e22f390576ce2() {
|
||||||
const ret = new Object();
|
const ret = new Object();
|
||||||
return addHeapObject(ret);
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbg_new_78feb108b6472713() {
|
export function __wbg_new_78feb108b6472713() {
|
||||||
const ret = new Array();
|
const ret = new Array();
|
||||||
return addHeapObject(ret);
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
|
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
|
||||||
getObject(arg0)[arg1 >>> 0] = takeObject(arg2);
|
arg0[arg1 >>> 0] = arg2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
|
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
|
||||||
getObject(arg0)[takeObject(arg1)] = takeObject(arg2);
|
arg0[arg1] = arg2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbindgen_debug_string(arg0, arg1) {
|
export function __wbindgen_debug_string(arg0, arg1) {
|
||||||
const ret = debugString(getObject(arg1));
|
const ret = debugString(arg1);
|
||||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
|
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
const len1 = WASM_VECTOR_LEN;
|
const len1 = WASM_VECTOR_LEN;
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbindgen_object_clone_ref(arg0) {
|
export function __wbindgen_init_externref_table() {
|
||||||
const ret = getObject(arg0);
|
const table = wasm.__wbindgen_export_2;
|
||||||
return addHeapObject(ret);
|
const offset = table.grow(4);
|
||||||
};
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
export function __wbindgen_object_drop_ref(arg0) {
|
table.set(offset + 1, null);
|
||||||
takeObject(arg0);
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbindgen_string_new(arg0, arg1) {
|
export function __wbindgen_string_new(arg0, arg1) {
|
||||||
const ret = getStringFromWasm0(arg0, arg1);
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
return addHeapObject(ret);
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function __wbindgen_throw(arg0, arg1) {
|
export function __wbindgen_throw(arg0, arg1) {
|
||||||
|
|||||||
BIN
src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm
generated
BIN
src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm
generated
Binary file not shown.
14
src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts
generated
vendored
14
src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts
generated
vendored
@@ -1,9 +1,11 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const memory: WebAssembly.Memory;
|
export const memory: WebAssembly.Memory;
|
||||||
export const escape_template: (a: number, b: number, c: number) => void;
|
export const escape_template: (a: number, b: number) => [number, number, number];
|
||||||
export const parse_template: (a: number, b: number, c: number) => void;
|
export const parse_template: (a: number, b: number) => [number, number, number];
|
||||||
export const unescape_template: (a: number, b: number, c: number) => void;
|
export const unescape_template: (a: number, b: number) => [number, number, number];
|
||||||
export const __wbindgen_export_0: (a: number, b: number) => number;
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
export const __wbindgen_export_1: (a: number, b: number, c: number, d: number) => number;
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
|
export const __wbindgen_export_2: WebAssembly.Table;
|
||||||
|
export const __externref_table_dealloc: (a: number) => void;
|
||||||
|
export const __wbindgen_start: () => void;
|
||||||
|
|||||||
58
src-web/components/ConfirmLargeResponseRequest.tsx
Normal file
58
src-web/components/ConfirmLargeResponseRequest.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody';
|
||||||
|
import { useToggle } from '../hooks/useToggle';
|
||||||
|
import { isProbablyTextContentType } from '../lib/contentType';
|
||||||
|
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import { InlineCode } from './core/InlineCode';
|
||||||
|
import { SizeTag } from './core/SizeTag';
|
||||||
|
import { HStack } from './core/Stacks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
response: HttpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||||
|
|
||||||
|
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||||
|
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||||
|
const isProbablyText = useMemo(() => {
|
||||||
|
const contentType = getContentTypeFromHeaders(response.headers);
|
||||||
|
return isProbablyTextContentType(contentType);
|
||||||
|
}, [response.headers]);
|
||||||
|
|
||||||
|
const contentLength = response.requestContentLength ?? 0;
|
||||||
|
const isLarge = contentLength > LARGE_BYTES;
|
||||||
|
if (!showLargeResponse && isLarge) {
|
||||||
|
return (
|
||||||
|
<Banner color="primary" className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
Showing content over{' '}
|
||||||
|
<InlineCode>
|
||||||
|
<SizeTag contentLength={LARGE_BYTES} />
|
||||||
|
</InlineCode>{' '}
|
||||||
|
may impact performance
|
||||||
|
</p>
|
||||||
|
<HStack wrap space={2}>
|
||||||
|
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||||
|
Reveal Request Body
|
||||||
|
</Button>
|
||||||
|
{isProbablyText && (
|
||||||
|
<CopyButton
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
|||||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||||
|
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { CountBadge } from './core/CountBadge';
|
import { CountBadge } from './core/CountBadge';
|
||||||
@@ -23,8 +24,9 @@ import type { TabItem } from './core/Tabs/Tabs';
|
|||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
||||||
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
||||||
import { ResponseTimeline } from './ResponseEvents';
|
import { RequestBodyViewer } from './RequestBodyViewer';
|
||||||
import { ResponseHeaders } from './ResponseHeaders';
|
import { ResponseHeaders } from './ResponseHeaders';
|
||||||
import { ResponseInfo } from './ResponseInfo';
|
import { ResponseInfo } from './ResponseInfo';
|
||||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||||
@@ -46,9 +48,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TAB_BODY = 'body';
|
const TAB_BODY = 'body';
|
||||||
|
const TAB_REQUEST = 'request';
|
||||||
const TAB_HEADERS = 'headers';
|
const TAB_HEADERS = 'headers';
|
||||||
const TAB_INFO = 'info';
|
const TAB_INFO = 'info';
|
||||||
const TAB_TIMELINE = 'events';
|
const TAB_TIMELINE = 'timeline';
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||||
@@ -76,6 +79,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: TAB_REQUEST,
|
||||||
|
label: 'Request',
|
||||||
|
rightSlot:
|
||||||
|
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TAB_HEADERS,
|
value: TAB_HEADERS,
|
||||||
label: 'Headers',
|
label: 'Headers',
|
||||||
@@ -98,11 +107,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
activeResponse?.headers,
|
activeResponse?.headers,
|
||||||
|
activeResponse?.requestContentLength,
|
||||||
|
activeResponse?.requestHeaders.length,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
responseEvents.data?.length,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
viewMode,
|
viewMode,
|
||||||
activeResponse?.requestHeaders.length,
|
|
||||||
responseEvents.data?.length,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const activeTab = activeTabs?.[activeRequestId];
|
const activeTab = activeTabs?.[activeRequestId];
|
||||||
@@ -200,8 +210,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
) : activeResponse.state === 'closed' &&
|
) : activeResponse.state === 'closed' &&
|
||||||
activeResponse.contentLength === 0 ? (
|
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||||
<EmptyStateText>Empty </EmptyStateText>
|
<EmptyStateText>Empty</EmptyStateText>
|
||||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
|
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
|
||||||
<EventStreamViewer response={activeResponse} />
|
<EventStreamViewer response={activeResponse} />
|
||||||
) : mimeType?.match(/^image\/svg/) ? (
|
) : mimeType?.match(/^image\/svg/) ? (
|
||||||
@@ -227,6 +237,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
<TabContent value={TAB_REQUEST}>
|
||||||
|
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||||
|
<RequestBodyViewer response={activeResponse} />
|
||||||
|
</ConfirmLargeResponseRequest>
|
||||||
|
</TabContent>
|
||||||
<TabContent value={TAB_HEADERS}>
|
<TabContent value={TAB_HEADERS}>
|
||||||
<ResponseHeaders response={activeResponse} />
|
<ResponseHeaders response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
@@ -234,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
<ResponseInfo response={activeResponse} />
|
<ResponseInfo response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_TIMELINE}>
|
<TabContent value={TAB_TIMELINE}>
|
||||||
<ResponseTimeline response={activeResponse} />
|
<HttpResponseTimeline response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Fragment, type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { AutoScroller } from './core/AutoScroller';
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -20,12 +20,8 @@ interface Props {
|
|||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResponseTimeline({ response }: Props) {
|
export function HttpResponseTimeline({ response }: Props) {
|
||||||
return (
|
return <Inner key={response.id} response={response} />;
|
||||||
<Fragment key={response.id}>
|
|
||||||
<Inner response={response} />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Inner({ response }: Props) {
|
function Inner({ response }: Props) {
|
||||||
@@ -252,20 +248,6 @@ type EventDisplay = {
|
|||||||
|
|
||||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'start_request':
|
|
||||||
return {
|
|
||||||
icon: 'info',
|
|
||||||
color: 'secondary',
|
|
||||||
label: 'Start',
|
|
||||||
summary: 'Request started',
|
|
||||||
};
|
|
||||||
case 'end_request':
|
|
||||||
return {
|
|
||||||
icon: 'info',
|
|
||||||
color: 'secondary',
|
|
||||||
label: 'End',
|
|
||||||
summary: 'Request complete',
|
|
||||||
};
|
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return {
|
return {
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
@@ -321,14 +303,14 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|||||||
icon: 'info',
|
icon: 'info',
|
||||||
color: 'secondary',
|
color: 'secondary',
|
||||||
label: 'Chunk',
|
label: 'Chunk',
|
||||||
summary: `${event.bytes} bytes sent`,
|
summary: `${formatBytes(event.bytes)} chunk sent`,
|
||||||
};
|
};
|
||||||
case 'chunk_received':
|
case 'chunk_received':
|
||||||
return {
|
return {
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
color: 'secondary',
|
color: 'secondary',
|
||||||
label: 'Chunk',
|
label: 'Chunk',
|
||||||
summary: `${event.bytes} bytes received`,
|
summary: `${formatBytes(event.bytes)} chunk received`,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
52
src-web/components/RequestBodyViewer.tsx
Normal file
52
src-web/components/RequestBodyViewer.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
|
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
|
||||||
|
import { languageFromContentType } from '../lib/contentType';
|
||||||
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: HttpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestBodyViewer({ response }: Props) {
|
||||||
|
return <RequestBodyViewerInner key={response.id} response={response} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestBodyViewerInner({ response }: Props) {
|
||||||
|
const { data, isLoading, error } = useHttpRequestBody(response);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<EmptyStateText>
|
||||||
|
<LoadingIcon />
|
||||||
|
</EmptyStateText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.bodyText == null || data.bodyText.length === 0) {
|
||||||
|
return <EmptyStateText>No request body</EmptyStateText>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bodyText } = data;
|
||||||
|
|
||||||
|
// Try to detect language from content-type header that was sent
|
||||||
|
const contentTypeHeader = response.requestHeaders.find(
|
||||||
|
(h) => h.name.toLowerCase() === 'content-type',
|
||||||
|
);
|
||||||
|
const contentType = contentTypeHeader?.value ?? null;
|
||||||
|
const language = languageFromContentType(contentType, bodyText);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
readOnly
|
||||||
|
defaultValue={bodyText}
|
||||||
|
language={language}
|
||||||
|
stateKey={`request.body.${response.id}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src-web/hooks/useHttpRequestBody.ts
Normal file
32
src-web/hooks/useHttpRequestBody.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
|
import { invokeCmd } from '../lib/tauri';
|
||||||
|
|
||||||
|
export function useHttpRequestBody(response: HttpResponse | null) {
|
||||||
|
return useQuery({
|
||||||
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
|
queryKey: ['request_body', response?.id, response?.state, response?.requestContentLength],
|
||||||
|
enabled: (response?.requestContentLength ?? 0) > 0,
|
||||||
|
queryFn: async () => {
|
||||||
|
return getRequestBodyText(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRequestBodyText(response: HttpResponse | null) {
|
||||||
|
if (response?.id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await invokeCmd<number[] | null>('cmd_http_request_body', {
|
||||||
|
responseId: response.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new Uint8Array(data);
|
||||||
|
const bodyText = new TextDecoder('utf-8', { fatal: false }).decode(body);
|
||||||
|
return { body, bodyText };
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type TauriCmd =
|
|||||||
| 'cmd_grpc_reflect'
|
| 'cmd_grpc_reflect'
|
||||||
| 'cmd_grpc_request_actions'
|
| 'cmd_grpc_request_actions'
|
||||||
| 'cmd_http_request_actions'
|
| 'cmd_http_request_actions'
|
||||||
|
| 'cmd_http_request_body'
|
||||||
| 'cmd_http_response_body'
|
| 'cmd_http_response_body'
|
||||||
| 'cmd_import_data'
|
| 'cmd_import_data'
|
||||||
| 'cmd_install_plugin'
|
| 'cmd_install_plugin'
|
||||||
|
|||||||
Reference in New Issue
Block a user