mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
CLI: handle gRPC and WebSocket requests, keep IDs on Yaak import (#816)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0bfe7a97a
commit
eba8dd5864
13 files changed
+1947
-197
No files matched your search
@@ -241,8 +241,12 @@ pub struct ExportArgs {
|
||||
#[arg(long, conflicts_with = "workspace_ids")]
|
||||
pub all: bool,
|
||||
|
||||
/// Include private environments in the export
|
||||
#[arg(long)]
|
||||
/// Include private environments in the export. Environments made in the app are private
|
||||
/// unless marked shareable, so they are left out by default, as the app's export does.
|
||||
#[arg(
|
||||
long = "include-private",
|
||||
visible_alias = "include-private-environments"
|
||||
)]
|
||||
pub include_private_environments: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use yaak::import;
|
||||
use yaak_models::export::{self, ExportDataParams};
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
ImportPlanWarning,
|
||||
};
|
||||
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
|
||||
|
||||
@@ -13,11 +14,14 @@ type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||
match import(ctx, args).await {
|
||||
Ok((result, items)) => {
|
||||
Ok((result, items, warnings)) => {
|
||||
println!("Imported {}", format_counts(&result));
|
||||
if let Some(skipped) = format_skipped(&items) {
|
||||
println!("Skipped {skipped}");
|
||||
}
|
||||
for warning in warnings {
|
||||
eprintln!("Warning: {}: {}", warning.title, warning.detail);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -43,7 +47,7 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||
async fn import(
|
||||
ctx: &CliContext,
|
||||
args: ImportArgs,
|
||||
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
|
||||
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>, Vec<ImportPlanWarning>)> {
|
||||
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
@@ -67,6 +71,15 @@ async fn import(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Everything would land in the one workspace, merging separate workspaces into it
|
||||
if workspace_id.is_some() && resources.workspaces.len() > 1 {
|
||||
return Err(format!(
|
||||
"{} contains {} workspaces, and --workspace-id imports into a single one. Run `yaak import {}` without --workspace-id to import each as its own workspace, or export one workspace at a time.",
|
||||
args.file.display(),
|
||||
resources.workspaces.len(),
|
||||
args.file.display(),
|
||||
));
|
||||
}
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
@@ -83,9 +96,10 @@ async fn import(
|
||||
)
|
||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||
let items = plan.items.clone();
|
||||
let warnings = plan.warnings.clone();
|
||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok((imported, items))
|
||||
Ok((imported, items, warnings))
|
||||
}
|
||||
|
||||
fn file_origin(path: &std::path::Path) -> ImportOrigin {
|
||||
|
||||
@@ -646,7 +646,43 @@ mod tests {
|
||||
fx.ok("delete_http_request", json!({ "id": id })).await,
|
||||
format!("Deleted: Created ({id})")
|
||||
);
|
||||
assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, Value::Null);
|
||||
assert_eq!(
|
||||
fx.err("get_http_request", json!({ "id": id })).await,
|
||||
format!("HTTP request with ID {id} not found")
|
||||
);
|
||||
fx.finish().await;
|
||||
}
|
||||
|
||||
/// QA sweep bug 6: `get_http_request` with a `wr_` ID or `rq_bogus` answered `"null"`
|
||||
/// with `isError: false`, and `delete_http_request` on a `wr_` ID failed obscurely.
|
||||
#[tokio::test]
|
||||
async fn http_request_tools_reject_unknown_and_non_http_ids() {
|
||||
let fx = Fixture::new(None).await;
|
||||
let websocket = fx
|
||||
.query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_websocket_request(
|
||||
&yaak_models::models::WebsocketRequest {
|
||||
workspace_id: fx.workspace_id.clone(),
|
||||
name: "Socket".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fx.err("get_http_request", json!({ "id": "rq_bogus" })).await,
|
||||
"HTTP request with ID rq_bogus not found"
|
||||
);
|
||||
let expected = format!(
|
||||
"{} is a WebSocket request, and these tools only work with HTTP requests. Use the Yaak app or the `yaak request` CLI commands for it.",
|
||||
websocket.id
|
||||
);
|
||||
assert_eq!(fx.err("get_http_request", json!({ "id": websocket.id })).await, expected);
|
||||
assert_eq!(fx.err("delete_http_request", json!({ "id": websocket.id })).await, expected);
|
||||
assert!(fx.query_manager.connect().get_websocket_request(&websocket.id).is_ok());
|
||||
fx.finish().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,18 +67,48 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a request of any type, with an error that names the ID rather than a table.
|
||||
pub(crate) fn get_any_request(ctx: &CliContext, request_id: &str) -> CommandResult<AnyRequest> {
|
||||
let db = ctx.db();
|
||||
if let Ok(request) = db.get_http_request(request_id) {
|
||||
return Ok(AnyRequest::HttpRequest(request));
|
||||
}
|
||||
if let Ok(request) = db.get_grpc_request(request_id) {
|
||||
return Ok(AnyRequest::GrpcRequest(request));
|
||||
}
|
||||
if let Ok(request) = db.get_websocket_request(request_id) {
|
||||
return Ok(AnyRequest::WebsocketRequest(request));
|
||||
}
|
||||
Err(format!("Request {request_id} not found"))
|
||||
}
|
||||
|
||||
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id, "request list")?;
|
||||
let requests = ctx
|
||||
.db()
|
||||
let db = ctx.db();
|
||||
let http = db
|
||||
.list_http_requests(&workspace_id)
|
||||
.map_err(|e| format!("Failed to list requests: {e}"))?;
|
||||
if requests.is_empty() {
|
||||
.map_err(|e| format!("Failed to list HTTP requests: {e}"))?;
|
||||
let grpc = db
|
||||
.list_grpc_requests(&workspace_id)
|
||||
.map_err(|e| format!("Failed to list gRPC requests: {e}"))?;
|
||||
let websocket = db
|
||||
.list_websocket_requests(&workspace_id)
|
||||
.map_err(|e| format!("Failed to list WebSocket requests: {e}"))?;
|
||||
|
||||
if http.is_empty() && grpc.is_empty() && websocket.is_empty() {
|
||||
println!("No requests found in workspace {}", workspace_id);
|
||||
} else {
|
||||
for request in requests {
|
||||
println!("{} - {} {}", request.id, request.method, request.name);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// HTTP lines keep their `<id> - <method> <name>` shape; the other types put their type
|
||||
// where the method would be, since they have no method of that kind.
|
||||
for request in http {
|
||||
println!("{} - {} {}", request.id, request.method, request.name);
|
||||
}
|
||||
for request in grpc {
|
||||
println!("{} - gRPC {}", request.id, request.name);
|
||||
}
|
||||
for request in websocket {
|
||||
println!("{} - WebSocket {}", request.id, request.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -112,20 +142,65 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
|
||||
/// HttpRequest with its unknown fields dropped, quietly producing a broken request
|
||||
/// (the gRPC method name lands in `method`, and `service` disappears).
|
||||
fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult {
|
||||
let Some(object) = payload.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(model) = object.get("model").and_then(Value::as_str)
|
||||
if let Some(model) = payload.get("model").and_then(Value::as_str)
|
||||
&& !model.is_empty()
|
||||
&& model != "http_request"
|
||||
{
|
||||
return Err(format!(
|
||||
"{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot work with {model} requests yet; use the Yaak app instead."
|
||||
"{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot create {model} requests yet; use the Yaak app instead."
|
||||
));
|
||||
}
|
||||
|
||||
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(HttpRequest))
|
||||
reject_unknown_fields::<HttpRequest>(payload, context, "an HTTP request", "http").map_err(
|
||||
|error| {
|
||||
format!("{error} If this is a gRPC or WebSocket request, the CLI cannot create it yet.")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// An update is a merge patch onto the stored request, so it must use that request's
|
||||
/// model and fields. Anything else would be dropped silently on save.
|
||||
fn reject_mismatched_update(payload: &Value, request: &AnyRequest) -> CommandResult {
|
||||
let (model, noun, schema_type) = match request {
|
||||
AnyRequest::HttpRequest(_) => ("http_request", "an HTTP request", "http"),
|
||||
AnyRequest::GrpcRequest(_) => ("grpc_request", "a gRPC request", "grpc"),
|
||||
AnyRequest::WebsocketRequest(_) => {
|
||||
("websocket_request", "a WebSocket request", "websocket")
|
||||
}
|
||||
};
|
||||
let context = "request update";
|
||||
if let Some(given) = payload.get("model").and_then(Value::as_str)
|
||||
&& !given.is_empty()
|
||||
&& given != model
|
||||
{
|
||||
return Err(format!(
|
||||
"{context} cannot change a request's type: the stored request is {noun} (\"{model}\"), but the payload has \"model\": \"{given}\"."
|
||||
));
|
||||
}
|
||||
match request {
|
||||
AnyRequest::HttpRequest(_) => {
|
||||
reject_unknown_fields::<HttpRequest>(payload, context, noun, schema_type)
|
||||
}
|
||||
AnyRequest::GrpcRequest(_) => {
|
||||
reject_unknown_fields::<GrpcRequest>(payload, context, noun, schema_type)
|
||||
}
|
||||
AnyRequest::WebsocketRequest(_) => {
|
||||
reject_unknown_fields::<WebsocketRequest>(payload, context, noun, schema_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_unknown_fields<T: schemars::JsonSchema>(
|
||||
payload: &Value,
|
||||
context: &str,
|
||||
noun: &str,
|
||||
schema_type: &str,
|
||||
) -> CommandResult {
|
||||
let Some(object) = payload.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(T))
|
||||
.ok()
|
||||
.and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned())
|
||||
.map(|properties| properties.keys().cloned().collect())
|
||||
@@ -140,7 +215,7 @@ fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult {
|
||||
|
||||
if !unknown.is_empty() {
|
||||
return Err(format!(
|
||||
"{context} got fields that are not part of an HTTP request: {}. If this is a gRPC or WebSocket request, the CLI cannot work with it yet. Run `yaak request schema http` for the valid fields.",
|
||||
"{context} got fields that are not part of {noun}: {}. Run `yaak request schema {schema_type}` for the valid fields.",
|
||||
unknown.join(", ")
|
||||
));
|
||||
}
|
||||
@@ -454,43 +529,67 @@ fn create(
|
||||
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
|
||||
let patch = parse_required_json(json, json_input, "request update")?;
|
||||
let id = require_id(&patch, "request update")?;
|
||||
reject_non_http_payload(&patch, "request update")?;
|
||||
|
||||
let existing = ctx
|
||||
.db()
|
||||
.get_http_request(&id)
|
||||
.map_err(|e| format!("Failed to get request for update: {e}"))?;
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
|
||||
let existing =
|
||||
get_any_request(ctx, &id).map_err(|e| format!("Failed to get request for update: {e}"))?;
|
||||
reject_mismatched_update(&patch, &existing)?;
|
||||
|
||||
let saved = ctx
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update request: {e}"))?;
|
||||
let context = "request update";
|
||||
let source = &UpdateSource::Sync;
|
||||
let saved_id = match existing {
|
||||
AnyRequest::HttpRequest(existing) => {
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, context)?;
|
||||
ctx.query_manager().with_tx(|tx| tx.upsert_http_request(&updated, source)).map(|r| r.id)
|
||||
}
|
||||
AnyRequest::GrpcRequest(existing) => {
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, context)?;
|
||||
ctx.query_manager().with_tx(|tx| tx.upsert_grpc_request(&updated, source)).map(|r| r.id)
|
||||
}
|
||||
AnyRequest::WebsocketRequest(existing) => {
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, context)?;
|
||||
ctx.query_manager()
|
||||
.with_tx(|tx| tx.upsert_websocket_request(&updated, source))
|
||||
.map(|r| r.id)
|
||||
}
|
||||
}
|
||||
.map_err(|e| format!("Failed to update request: {e}"))?;
|
||||
|
||||
println!("Updated request: {}", saved.id);
|
||||
println!("Updated request: {saved_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show(ctx: &CliContext, request_id: &str) -> CommandResult {
|
||||
let request =
|
||||
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
|
||||
let output = serde_json::to_string_pretty(&request)
|
||||
.map_err(|e| format!("Failed to serialize request: {e}"))?;
|
||||
let request = get_any_request(ctx, request_id)?;
|
||||
let output = match &request {
|
||||
AnyRequest::HttpRequest(r) => serde_json::to_string_pretty(r),
|
||||
AnyRequest::GrpcRequest(r) => serde_json::to_string_pretty(r),
|
||||
AnyRequest::WebsocketRequest(r) => serde_json::to_string_pretty(r),
|
||||
}
|
||||
.map_err(|e| format!("Failed to serialize request: {e}"))?;
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
|
||||
// Resolved before asking, so an unknown ID fails without a prompt
|
||||
let request = get_any_request(ctx, request_id)?;
|
||||
if !yes && !confirm_delete("request", request_id) {
|
||||
println!("Aborted");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
let source = &UpdateSource::Sync;
|
||||
let deleted_id = ctx
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(request_id, &UpdateSource::Sync))
|
||||
.with_tx(|tx| match &request {
|
||||
AnyRequest::HttpRequest(r) => tx.delete_http_request_by_id(&r.id, source).map(|r| r.id),
|
||||
AnyRequest::GrpcRequest(r) => tx.delete_grpc_request_by_id(&r.id, source).map(|r| r.id),
|
||||
AnyRequest::WebsocketRequest(r) => {
|
||||
tx.delete_websocket_request_by_id(&r.id, source).map(|r| r.id)
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete request: {e}"))?;
|
||||
println!("Deleted request: {}", deleted.id);
|
||||
println!("Deleted request: {deleted_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -503,7 +602,7 @@ pub async fn send_request_by_id(
|
||||
verbose: bool,
|
||||
) -> Result<(), String> {
|
||||
let request =
|
||||
ctx.db().get_any_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
|
||||
get_any_request(ctx, request_id).map_err(|e| format!("Failed to get request: {e}"))?;
|
||||
match request {
|
||||
AnyRequest::HttpRequest(http_request) => {
|
||||
send_http_request_by_id(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use crate::cli::{ResponseArgs, ResponseCommands};
|
||||
use crate::commands::request::get_any_request;
|
||||
use crate::context::CliContext;
|
||||
use crate::utils::confirm::confirm_delete;
|
||||
use crate::utils::workspace::resolve_workspace_id;
|
||||
use std::io::Write;
|
||||
use yaak_models::models::HttpResponse;
|
||||
use yaak_models::models::{
|
||||
GrpcConnection, GrpcEventType, HttpResponse, WebsocketConnection, WebsocketEventType,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
@@ -25,37 +29,124 @@ pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a response ID, or a request ID meaning "the latest response for that request".
|
||||
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
|
||||
if let Ok(response) = ctx.db().get_http_response(id) {
|
||||
return Ok(response);
|
||||
/// What a request leaves behind when sent: an HTTP response, or the connection a gRPC or
|
||||
/// WebSocket request opened, whose messages are its events.
|
||||
enum StoredResponse {
|
||||
Http(HttpResponse),
|
||||
Grpc(GrpcConnection),
|
||||
Websocket(WebsocketConnection),
|
||||
}
|
||||
|
||||
impl StoredResponse {
|
||||
fn id(&self) -> &str {
|
||||
match self {
|
||||
StoredResponse::Http(r) => &r.id,
|
||||
StoredResponse::Grpc(c) => &c.id,
|
||||
StoredResponse::Websocket(c) => &c.id,
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.db().get_http_request(id).is_ok() {
|
||||
return ctx
|
||||
.db()
|
||||
.list_http_responses_for_request(id, Some(1))
|
||||
.map_err(|e| format!("Failed to list responses: {e}"))?
|
||||
fn created_at(&self) -> impl Ord + use<> {
|
||||
match self {
|
||||
StoredResponse::Http(r) => r.created_at,
|
||||
StoredResponse::Grpc(c) => c.created_at,
|
||||
StoredResponse::Websocket(c) => c.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a response or connection ID, or a request ID meaning "the latest response for that
|
||||
/// request".
|
||||
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<StoredResponse> {
|
||||
let db = ctx.db();
|
||||
if let Ok(response) = db.get_http_response(id) {
|
||||
return Ok(StoredResponse::Http(response));
|
||||
}
|
||||
if let Ok(connection) = db.get_grpc_connection(id) {
|
||||
return Ok(StoredResponse::Grpc(connection));
|
||||
}
|
||||
if let Ok(connection) = db.get_websocket_connection(id) {
|
||||
return Ok(StoredResponse::Websocket(connection));
|
||||
}
|
||||
|
||||
let Ok(request) = get_any_request(ctx, id) else {
|
||||
return Err(format!("Could not resolve ID '{id}' as a response or request"));
|
||||
};
|
||||
let latest = list_for_request(ctx, &request, Some(1))?.into_iter().next();
|
||||
latest.ok_or_else(|| format!("Request {id} has no stored responses yet"))
|
||||
}
|
||||
|
||||
fn list_for_request(
|
||||
ctx: &CliContext,
|
||||
request: &AnyRequest,
|
||||
limit: Option<u64>,
|
||||
) -> CommandResult<Vec<StoredResponse>> {
|
||||
let db = ctx.db();
|
||||
let error = |e: yaak_models::error::Error| format!("Failed to list responses: {e}");
|
||||
Ok(match request {
|
||||
AnyRequest::HttpRequest(r) => db
|
||||
.list_http_responses_for_request(&r.id, limit)
|
||||
.map_err(error)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("Request {id} has no stored responses yet"));
|
||||
}
|
||||
.map(StoredResponse::Http)
|
||||
.collect(),
|
||||
AnyRequest::GrpcRequest(r) => db
|
||||
.list_grpc_connections_for_request(&r.id, limit)
|
||||
.map_err(error)?
|
||||
.into_iter()
|
||||
.map(StoredResponse::Grpc)
|
||||
.collect(),
|
||||
AnyRequest::WebsocketRequest(r) => {
|
||||
let connections = db.list_websocket_connections_for_request(&r.id).map_err(error)?;
|
||||
let limit = limit.map_or(usize::MAX, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
connections.into_iter().take(limit).map(StoredResponse::Websocket).collect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Err(format!("Could not resolve ID '{id}' as a response or request"))
|
||||
/// Every kind of stored response in a workspace, newest first, with `limit` applied to the
|
||||
/// merged history rather than to each kind.
|
||||
fn list_for_workspace(
|
||||
ctx: &CliContext,
|
||||
workspace_id: &str,
|
||||
limit: Option<u64>,
|
||||
) -> CommandResult<Vec<StoredResponse>> {
|
||||
let db = ctx.db();
|
||||
let error = |e: yaak_models::error::Error| format!("Failed to list responses: {e}");
|
||||
// Each kind comes back newest first, so the newest `limit` overall are among the newest
|
||||
// `limit` of each kind.
|
||||
let mut responses = db
|
||||
.list_http_responses(workspace_id, limit)
|
||||
.map_err(error)?
|
||||
.into_iter()
|
||||
.map(StoredResponse::Http)
|
||||
.chain(
|
||||
db.list_grpc_connections(workspace_id)
|
||||
.map_err(error)?
|
||||
.into_iter()
|
||||
.map(StoredResponse::Grpc),
|
||||
)
|
||||
.chain(
|
||||
db.list_websocket_connections(workspace_id)
|
||||
.map_err(error)?
|
||||
.into_iter()
|
||||
.map(StoredResponse::Websocket),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
responses.sort_by_key(|r| std::cmp::Reverse(r.created_at()));
|
||||
if let Some(limit) = limit {
|
||||
responses.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
|
||||
}
|
||||
Ok(responses)
|
||||
}
|
||||
|
||||
fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult {
|
||||
// An explicit request ID scopes to that request; anything else lists the workspace.
|
||||
let responses = match id {
|
||||
Some(id) if ctx.db().get_http_request(id).is_ok() => ctx
|
||||
.db()
|
||||
.list_http_responses_for_request(id, limit)
|
||||
.map_err(|e| format!("Failed to list responses: {e}"))?,
|
||||
other => {
|
||||
let workspace_id = resolve_workspace_id(ctx, other, "response list")?;
|
||||
ctx.db()
|
||||
.list_http_responses(&workspace_id, limit)
|
||||
.map_err(|e| format!("Failed to list responses: {e}"))?
|
||||
let responses = match id.map(|id| get_any_request(ctx, id)) {
|
||||
Some(Ok(request)) => list_for_request(ctx, &request, limit)?,
|
||||
_ => {
|
||||
let workspace_id = resolve_workspace_id(ctx, id, "response list")?;
|
||||
list_for_workspace(ctx, &workspace_id, limit)?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,7 +156,20 @@ fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult
|
||||
}
|
||||
|
||||
for response in responses {
|
||||
let status = match response.error {
|
||||
let (error, status, url, elapsed) = match &response {
|
||||
StoredResponse::Http(r) => {
|
||||
let reason = r.status_reason.clone().unwrap_or_default();
|
||||
let status = format!("{} {}", r.status, reason).trim_end().to_string();
|
||||
(r.error.as_deref(), status, r.url.as_str(), r.elapsed)
|
||||
}
|
||||
StoredResponse::Grpc(c) => {
|
||||
(c.error.as_deref(), format!("gRPC status {}", c.status), c.url.as_str(), c.elapsed)
|
||||
}
|
||||
StoredResponse::Websocket(c) => {
|
||||
(c.error.as_deref(), c.status.to_string(), c.url.as_str(), c.elapsed)
|
||||
}
|
||||
};
|
||||
let status = match error {
|
||||
// Errors can be a paragraph of nested debug output; `show` has the full text.
|
||||
Some(error) => {
|
||||
let first_line = error.lines().next().unwrap_or_default();
|
||||
@@ -75,34 +179,70 @@ fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult
|
||||
};
|
||||
format!("ERROR {summary}")
|
||||
}
|
||||
None => {
|
||||
let reason = response.status_reason.unwrap_or_default();
|
||||
format!("{} {}", response.status, reason).trim_end().to_string()
|
||||
}
|
||||
None => status,
|
||||
};
|
||||
println!("{} - {} {} ({}ms)", response.id, status, response.url, response.elapsed);
|
||||
println!("{} - {} {} ({}ms)", response.id(), status, url, elapsed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show(ctx: &CliContext, id: &str) -> CommandResult {
|
||||
let response = resolve_response(ctx, id)?;
|
||||
let output = serde_json::to_string_pretty(&response)
|
||||
.map_err(|e| format!("Failed to serialize response: {e}"))?;
|
||||
let output = match resolve_response(ctx, id)? {
|
||||
StoredResponse::Http(r) => serde_json::to_string_pretty(&r),
|
||||
StoredResponse::Grpc(c) => serde_json::to_string_pretty(&c),
|
||||
StoredResponse::Websocket(c) => serde_json::to_string_pretty(&c),
|
||||
}
|
||||
.map_err(|e| format!("Failed to serialize response: {e}"))?;
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An HTTP body as stored; for a gRPC or WebSocket connection, each message the server sent,
|
||||
/// oldest first, one per line.
|
||||
fn body(ctx: &CliContext, id: &str) -> CommandResult {
|
||||
let response = resolve_response(ctx, id)?;
|
||||
let Some(body_path) = response.body_path else {
|
||||
return Err(format!("Response {} has no stored body", response.id));
|
||||
let bytes = match resolve_response(ctx, id)? {
|
||||
StoredResponse::Http(response) => {
|
||||
let Some(body_path) = response.body_path else {
|
||||
return Err(format!("Response {} has no stored body", response.id));
|
||||
};
|
||||
std::fs::read(&body_path)
|
||||
.map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?
|
||||
}
|
||||
StoredResponse::Grpc(connection) => {
|
||||
let mut events = ctx
|
||||
.db()
|
||||
.list_grpc_events(&connection.id)
|
||||
.map_err(|e| format!("Failed to list connection events: {e}"))?;
|
||||
events.sort_by_key(|e| e.created_at);
|
||||
let mut bytes = Vec::new();
|
||||
for event in events.iter().filter(|e| e.event_type == GrpcEventType::ServerMessage) {
|
||||
bytes.extend_from_slice(event.content.as_bytes());
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
StoredResponse::Websocket(connection) => {
|
||||
let mut events = ctx
|
||||
.db()
|
||||
.list_websocket_events(&connection.id)
|
||||
.map_err(|e| format!("Failed to list connection events: {e}"))?;
|
||||
events.sort_by_key(|e| e.created_at);
|
||||
let mut bytes = Vec::new();
|
||||
for event in events.iter().filter(|e| {
|
||||
e.is_server
|
||||
&& matches!(
|
||||
e.message_type,
|
||||
WebsocketEventType::Text | WebsocketEventType::Binary
|
||||
)
|
||||
}) {
|
||||
bytes.extend_from_slice(&event.message);
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = std::fs::read(&body_path)
|
||||
.map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?;
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(&bytes).map_err(|e| format!("Failed to write response body: {e}"))?;
|
||||
stdout.flush().map_err(|e| format!("Failed to flush response body: {e}"))?;
|
||||
@@ -112,14 +252,29 @@ fn body(ctx: &CliContext, id: &str) -> CommandResult {
|
||||
fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
// Deleting is scoped to a whole request or workspace, since a single stored
|
||||
// response is rarely the thing someone wants to remove.
|
||||
if ctx.db().get_http_request(id).is_ok() {
|
||||
if let Ok(request) = get_any_request(ctx, id) {
|
||||
if !yes && !confirm_delete("responses for request", id) {
|
||||
println!("Aborted");
|
||||
return Ok(());
|
||||
}
|
||||
let source = &UpdateSource::Sync;
|
||||
let count = ctx
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_all_http_responses_for_request(id, &UpdateSource::Sync))
|
||||
.with_tx(|tx| match &request {
|
||||
AnyRequest::HttpRequest(r) => {
|
||||
tx.delete_all_http_responses_for_request(&r.id, source)
|
||||
}
|
||||
AnyRequest::GrpcRequest(r) => {
|
||||
let count = tx.list_grpc_connections_for_request(&r.id, None)?.len();
|
||||
tx.delete_all_grpc_connections_for_request(&r.id, source)?;
|
||||
Ok(count)
|
||||
}
|
||||
AnyRequest::WebsocketRequest(r) => {
|
||||
let count = tx.list_websocket_connections_for_request(&r.id)?.len();
|
||||
tx.delete_all_websocket_connections_for_request(&r.id, source)?;
|
||||
Ok(count)
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for request {id}");
|
||||
return Ok(());
|
||||
@@ -133,7 +288,13 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
let count = ctx
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||
let source = &UpdateSource::Sync;
|
||||
let grpc = tx.list_grpc_connections(&workspace_id)?.len();
|
||||
let websocket = tx.list_websocket_connections(&workspace_id)?.len();
|
||||
let http = tx.delete_all_http_responses_for_workspace(&workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(&workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(&workspace_id, source)?;
|
||||
Ok::<_, yaak_models::error::Error>(http + grpc + websocket)
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for workspace {workspace_id}");
|
||||
|
||||
@@ -179,6 +179,22 @@ async fn send_many(
|
||||
let mut success_count = 0usize;
|
||||
let mut failures: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// The CLI sends HTTP only, so the rest of a folder is reported, not counted as failing.
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut request_ids = request_ids;
|
||||
request_ids.retain(|id| match request::get_any_request(ctx, id) {
|
||||
Ok(AnyRequest::GrpcRequest(r)) => {
|
||||
skipped.push(format!("{id} - gRPC {}", r.name));
|
||||
false
|
||||
}
|
||||
Ok(AnyRequest::WebsocketRequest(r)) => {
|
||||
skipped.push(format!("{id} - WebSocket {}", r.name));
|
||||
false
|
||||
}
|
||||
// A request that has gone missing since it was listed fails when sent, as before
|
||||
Ok(AnyRequest::HttpRequest(_)) | Err(_) => true,
|
||||
});
|
||||
|
||||
match mode {
|
||||
ExecutionMode::Sequential => {
|
||||
for request_id in request_ids {
|
||||
@@ -229,7 +245,18 @@ async fn send_many(
|
||||
}
|
||||
|
||||
let failure_count = failures.len();
|
||||
println!("Send summary: {success_count} succeeded, {failure_count} failed");
|
||||
if skipped.is_empty() {
|
||||
println!("Send summary: {success_count} succeeded, {failure_count} failed");
|
||||
} else {
|
||||
println!(
|
||||
"Send summary: {success_count} succeeded, {failure_count} failed, {} skipped",
|
||||
skipped.len()
|
||||
);
|
||||
println!("Skipped, since the CLI can only send HTTP requests so far:");
|
||||
for line in &skipped {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
|
||||
if failure_count == 0 {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod common;
|
||||
|
||||
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||
use common::{cli_cmd, parse_created_id, query_manager, seed_folder, seed_request, seed_workspace};
|
||||
use predicates::str::contains;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
@@ -322,3 +322,249 @@ fn re_import_leaves_deleted_resources_alone() {
|
||||
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
|
||||
assert_eq!(requests[0].name, "Request A");
|
||||
}
|
||||
|
||||
fn path_str(path: &std::path::Path) -> &str {
|
||||
path.to_str().expect("path is utf-8")
|
||||
}
|
||||
|
||||
fn created_id(data_dir: &std::path::Path, args: &[&str]) -> String {
|
||||
let assert = cli_cmd(data_dir).args(args).assert().success();
|
||||
parse_created_id(&assert.get_output().stdout, &args.join(" "))
|
||||
}
|
||||
|
||||
/// A workspace made the way the skill describes: base and Staging environments and a request.
|
||||
/// Returns the workspace, base environment, Staging environment, and request IDs.
|
||||
fn cli_made_workspace(data_dir: &std::path::Path) -> (String, String, String, String) {
|
||||
let workspace_id = created_id(data_dir, &["workspace", "create", "--name", "CI WS"]);
|
||||
let list = cli_cmd(data_dir).args(["environment", "list", &workspace_id]).assert().success();
|
||||
let base_id = String::from_utf8_lossy(&list.get_output().stdout)
|
||||
.lines()
|
||||
.find(|l| l.ends_with("(workspace)"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.expect("base environment listed")
|
||||
.to_string();
|
||||
let staging_id =
|
||||
created_id(data_dir, &["environment", "create", &workspace_id, "--name", "Staging"]);
|
||||
let request_id = created_id(
|
||||
data_dir,
|
||||
&[
|
||||
"request",
|
||||
"create",
|
||||
&workspace_id,
|
||||
"--name",
|
||||
"Health",
|
||||
"--url",
|
||||
"https://example.com",
|
||||
],
|
||||
);
|
||||
(workspace_id, base_id, staging_id, request_id)
|
||||
}
|
||||
|
||||
fn read_json(path: &std::path::Path) -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("read file")).expect("JSON")
|
||||
}
|
||||
|
||||
/// QA sweep bug 2: `yaak import req_in_existing_folder.json --workspace-id <W>` put requests
|
||||
/// that name an existing folder of W at the workspace root.
|
||||
#[test]
|
||||
fn import_keeps_a_folder_id_that_names_an_existing_folder() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("req_in_existing_folder.json");
|
||||
seed_workspace(data_dir, "wk_target");
|
||||
seed_folder(data_dir, "wk_target", "fl_existing");
|
||||
|
||||
std::fs::write(
|
||||
&import_path,
|
||||
r#"{"yaakVersion":"test","yaakSchema":4,"resources":{"workspaces":[],"environments":[],"folders":[],
|
||||
"httpRequests":[{"model":"http_request","id":"rq_newinfold1","workspaceId":"wk_target","folderId":"fl_existing","name":"Into existing","method":"GET","url":"http://127.0.0.1/x"}],
|
||||
"grpcRequests":[{"model":"grpc_request","id":"gr_newinfold1","workspaceId":"wk_target","folderId":"fl_existing","name":"gRPC into existing","url":"127.0.0.1:50051"}],
|
||||
"websocketRequests":[]}}"#,
|
||||
)
|
||||
.expect("write fixture");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
path_str(&import_path),
|
||||
"--workspace-id",
|
||||
"wk_target",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 HTTP request, 1 gRPC request"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let http = db.list_http_requests("wk_target").expect("list HTTP");
|
||||
let grpc = db.list_grpc_requests("wk_target").expect("list gRPC");
|
||||
assert_eq!(http[0].folder_id.as_deref(), Some("fl_existing"), "{http:?}");
|
||||
assert_eq!(grpc[0].folder_id.as_deref(), Some("fl_existing"), "{grpc:?}");
|
||||
}
|
||||
|
||||
/// QA sweep bug 3: `yaak export e.json <W>`, edit the file, `yaak import e.json --workspace-id
|
||||
/// <W>` made copies with new IDs, a second base environment, and "(Imported)" environments.
|
||||
#[test]
|
||||
fn re_importing_an_edited_export_updates_the_workspace_in_place() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let export_path = temp_dir.path().join("exp.json");
|
||||
let (workspace_id, base_id, staging_id, request_id) = cli_made_workspace(data_dir);
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"export",
|
||||
path_str(&export_path),
|
||||
&workspace_id,
|
||||
"--include-private",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
let mut exported = read_json(&export_path);
|
||||
exported["resources"]["httpRequests"][0]["name"] = "Health (edited)".into();
|
||||
std::fs::write(&export_path, exported.to_string()).expect("write edited export");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
path_str(&export_path),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 HTTP request"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 1, "no copies: {requests:?}");
|
||||
assert_eq!(requests[0].id, request_id);
|
||||
assert_eq!(requests[0].name, "Health (edited)");
|
||||
|
||||
let mut environment_ids =
|
||||
db.list_environments(&workspace_id).expect("list environments").into_iter().map(|e| e.id);
|
||||
let mut expected = vec![base_id.clone(), staging_id];
|
||||
expected.sort();
|
||||
let mut actual = environment_ids.by_ref().collect::<Vec<_>>();
|
||||
actual.sort();
|
||||
assert_eq!(actual, expected, "no second base environment and no Imported copies");
|
||||
assert_eq!(db.get_base_environment(&workspace_id).expect("base").id, base_id);
|
||||
}
|
||||
|
||||
/// QA sweep bug 7: the skill's CI recipe, `yaak export` then `yaak import` into a fresh
|
||||
/// `--data-dir`, gave every model a new ID, so the IDs in the recipe no longer worked.
|
||||
#[test]
|
||||
fn import_into_a_fresh_data_dir_keeps_the_ids() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path().join("dev");
|
||||
let ci_dir = temp_dir.path().join("ci");
|
||||
let export_path = temp_dir.path().join("ci-export.json");
|
||||
let (workspace_id, base_id, staging_id, request_id) = cli_made_workspace(&data_dir);
|
||||
|
||||
cli_cmd(&data_dir)
|
||||
.args([
|
||||
"export",
|
||||
path_str(&export_path),
|
||||
&workspace_id,
|
||||
"--include-private",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
cli_cmd(&ci_dir).args(["import", path_str(&export_path)]).assert().success();
|
||||
|
||||
let query_manager = query_manager(&ci_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.get_workspace(&workspace_id).expect("workspace keeps its ID").name, "CI WS");
|
||||
assert_eq!(db.get_http_request(&request_id).expect("request keeps its ID").name, "Health");
|
||||
assert_eq!(db.get_base_environment(&workspace_id).expect("base").id, base_id);
|
||||
assert_eq!(db.get_environment(&staging_id).expect("Staging keeps its ID").name, "Staging");
|
||||
}
|
||||
|
||||
/// QA sweep bug 8: there was no `--include-private`, so the environments the CLI makes, which
|
||||
/// are private like the app's, could not be exported without the long flag.
|
||||
#[test]
|
||||
fn export_includes_cli_made_environments_only_with_include_private() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let export_path = temp_dir.path().join("export.json");
|
||||
let (workspace_id, base_id, staging_id, _) = cli_made_workspace(data_dir);
|
||||
|
||||
let exported_ids = |args: &[&str]| {
|
||||
cli_cmd(data_dir).args(["export", path_str(&export_path)]).args(args).assert().success();
|
||||
let mut ids = read_json(&export_path)["resources"]["environments"]
|
||||
.as_array()
|
||||
.expect("environments")
|
||||
.iter()
|
||||
.map(|e| e["id"].as_str().expect("id").to_string())
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
|
||||
assert!(exported_ids(&[&workspace_id]).is_empty(), "CLI-made environments are private");
|
||||
|
||||
let mut all = vec![base_id, staging_id];
|
||||
all.sort();
|
||||
assert_eq!(exported_ids(&[&workspace_id, "--include-private"]), all);
|
||||
assert_eq!(exported_ids(&[&workspace_id, "--include-private-environments"]), all);
|
||||
}
|
||||
|
||||
/// QA sweep bug 15: the planner's warnings, such as IDs taken by another workspace, were
|
||||
/// computed and then never printed.
|
||||
#[test]
|
||||
fn import_prints_the_plan_warnings_after_the_counts() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let export_path = temp_dir.path().join("e9.json");
|
||||
let (workspace_id, ..) = cli_made_workspace(data_dir);
|
||||
let other_id = created_id(data_dir, &["workspace", "create", "--name", "Other WS"]);
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"export",
|
||||
path_str(&export_path),
|
||||
&workspace_id,
|
||||
"--include-private",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", path_str(&export_path), "--workspace-id", &other_id])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 2 environments, 1 HTTP request"))
|
||||
.stderr(contains(
|
||||
"Warning: New IDs assigned: 3 resources already exist in CI WS, so the imported copies get new IDs",
|
||||
))
|
||||
.stderr(contains("Warning: Base environment kept separate:"));
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", path_str(&export_path)])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Warning: New IDs assigned: 4 resources already exist in CI WS"));
|
||||
}
|
||||
|
||||
/// QA sweep bug 16: `yaak import e_two.json --workspace-id <W>` merged both workspaces of the
|
||||
/// file into W.
|
||||
#[test]
|
||||
fn import_with_workspace_id_refuses_a_file_with_several_workspaces() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let export_path = temp_dir.path().join("e_two.json");
|
||||
let first = created_id(data_dir, &["workspace", "create", "--name", "First"]);
|
||||
let second = created_id(data_dir, &["workspace", "create", "--name", "Second"]);
|
||||
seed_request(data_dir, &first, "rq_first");
|
||||
cli_cmd(data_dir).args(["export", path_str(&export_path), &first, &second]).assert().success();
|
||||
let target = created_id(data_dir, &["workspace", "create", "--name", "Target"]);
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", path_str(&export_path), "--workspace-id", &target])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("contains 2 workspaces"))
|
||||
.stderr(contains("without --workspace-id to import each as its own workspace"));
|
||||
let query_manager = query_manager(data_dir);
|
||||
assert!(query_manager.connect().list_http_requests(&target).expect("list").is_empty());
|
||||
}
|
||||
@@ -223,6 +223,47 @@ async fn mcp_send_settings_reach_template_functions() {
|
||||
assert!(child.wait().await.unwrap().success());
|
||||
}
|
||||
|
||||
/// QA sweep bug 6: the CLI has no window, so `show_toast` prints the toast to stderr (stdout is
|
||||
/// the protocol channel) the way the CLI prints plugin toasts, and reports it as shown.
|
||||
#[tokio::test]
|
||||
async fn mcp_show_toast_prints_the_message_to_stderr() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_yaak"))
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap());
|
||||
let client = ().serve(io).await.unwrap();
|
||||
|
||||
let arguments = json!({ "message": "Build finished" });
|
||||
let Value::Object(arguments) = arguments else {
|
||||
unreachable!()
|
||||
};
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("show_toast").with_arguments(arguments))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(result.is_error, Some(true), "{result:?}");
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "✓ Toast shown: \"Build finished\"");
|
||||
|
||||
client.cancel().await.unwrap();
|
||||
child.kill().await.unwrap();
|
||||
let mut output = String::new();
|
||||
stderr.read_to_string(&mut output).await.unwrap();
|
||||
assert!(output.lines().any(|l| l == "[mcp] Build finished"), "stderr: {output}");
|
||||
}
|
||||
|
||||
/// Spawns `yaak mcp --http` with `args` and returns the child, the URL it
|
||||
/// printed, and the token it printed or was given.
|
||||
async fn spawn_http(data_dir: &std::path::Path, args: &[&str]) -> (tokio::process::Child, String, String) {
|
||||
@@ -283,7 +324,9 @@ async fn post(
|
||||
.last()
|
||||
.map(str::trim)
|
||||
.unwrap_or(text.trim());
|
||||
(status, headers, serde_json::from_str(message).ok())
|
||||
// A plain-text body, such as the reason for a 500, comes back as a string to show
|
||||
let body = serde_json::from_str(message).unwrap_or_else(|_| Value::String(text.clone()));
|
||||
(status, headers, Some(body))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -309,7 +352,7 @@ async fn mcp_http_requires_the_token() {
|
||||
|
||||
let auth = Some("Bearer s3cret");
|
||||
let (status, headers, body) = post(&client, &url, auth, None, initialize).await;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "initialize answered {body:?}");
|
||||
let session = headers["mcp-session-id"].to_str().unwrap().to_string();
|
||||
assert_eq!(body.unwrap()["result"]["serverInfo"]["name"], "yaak-mcp-server");
|
||||
|
||||
|
||||
@@ -289,3 +289,81 @@ fn request_send_websocket_returns_explicit_nyi_error() {
|
||||
.code(1)
|
||||
.stderr(contains("WebSocket request send is not implemented yet in yaak-cli"));
|
||||
}
|
||||
|
||||
/// QA sweep bug 1: `request list`, `show`, `update` and `delete` were HTTP-only, so gRPC and
|
||||
/// WebSocket requests were missing from the list and failed everywhere else.
|
||||
#[test]
|
||||
fn request_commands_handle_grpc_and_websocket_requests() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
seed_request(data_dir, "wk_test", "rq_types");
|
||||
seed_grpc_request(data_dir, "wk_test", "gr_types");
|
||||
seed_websocket_request(data_dir, "wk_test", "wr_types");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["request", "list", "wk_test"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("rq_types - GET Seeded Request"))
|
||||
.stdout(contains("gr_types - gRPC Seeded gRPC Request"))
|
||||
.stdout(contains("wr_types - WebSocket Seeded WebSocket Request"));
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["request", "show", "gr_types"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\"model\": \"grpc_request\""));
|
||||
cli_cmd(data_dir)
|
||||
.args(["request", "show", "wr_types"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\"model\": \"websocket_request\""));
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"request",
|
||||
"update",
|
||||
r#"{"id":"wr_types","name":"Renamed Socket"}"#,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Updated request: wr_types"));
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"request",
|
||||
"update",
|
||||
r#"{"id":"gr_types","service":"pkg.Greeter"}"#,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"request",
|
||||
"update",
|
||||
r#"{"id":"gr_types","method":"GET","bodyType":"other"}"#,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("not part of a gRPC request: bodyType"));
|
||||
{
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.get_websocket_request("wr_types").unwrap().name, "Renamed Socket");
|
||||
assert_eq!(
|
||||
db.get_grpc_request("gr_types").unwrap().service.as_deref(),
|
||||
Some("pkg.Greeter")
|
||||
);
|
||||
}
|
||||
|
||||
cli_cmd(data_dir).args(["request", "delete", "gr_types", "--yes"]).assert().success();
|
||||
cli_cmd(data_dir).args(["request", "delete", "wr_types", "--yes"]).assert().success();
|
||||
cli_cmd(data_dir)
|
||||
.args(["request", "show", "wr_types"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("Request wr_types not found"));
|
||||
let query_manager = query_manager(data_dir);
|
||||
assert!(query_manager.connect().get_grpc_request("gr_types").is_err());
|
||||
assert!(query_manager.connect().get_websocket_request("wr_types").is_err());
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
cli_cmd, query_manager, seed_grpc_request, seed_request, seed_websocket_request, seed_workspace,
|
||||
};
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{
|
||||
GrpcConnection, GrpcEvent, GrpcEventType, HttpResponse, WebsocketConnection, WebsocketEvent,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
/// QA sweep bug 4: `response list/show/body/delete` given a `gr_` or `wr_` ID could not
|
||||
/// find the request, so its connections were unreachable.
|
||||
#[test]
|
||||
fn response_commands_handle_grpc_and_websocket_connections() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
seed_grpc_request(data_dir, "wk_test", "gr_conn");
|
||||
seed_websocket_request(data_dir, "wk_test", "wr_conn");
|
||||
|
||||
query_manager(data_dir)
|
||||
.with_tx(|tx| {
|
||||
let source = &UpdateSource::Sync;
|
||||
let ws = tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
id: "wc_conn".into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
request_id: "wr_conn".into(),
|
||||
url: "wss://example.com/socket".into(),
|
||||
status: 101,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
for (is_server, message) in [(false, "ping me"), (true, "hello"), (true, "world")] {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
workspace_id: "wk_test".into(),
|
||||
request_id: "wr_conn".into(),
|
||||
connection_id: ws.id.clone(),
|
||||
is_server,
|
||||
message: message.as_bytes().to_vec(),
|
||||
message_type: WebsocketEventType::Text,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
let grpc = tx.upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
id: "gc_conn".into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
request_id: "gr_conn".into(),
|
||||
url: "https://example.com".into(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
workspace_id: "wk_test".into(),
|
||||
request_id: "gr_conn".into(),
|
||||
connection_id: grpc.id.clone(),
|
||||
content: "{\"message\":\"hi\"}".into(),
|
||||
event_type: GrpcEventType::ServerMessage,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("seed connections");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "list", "wr_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("wc_conn - 101 wss://example.com/socket"));
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "list", "gr_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("gc_conn - gRPC status 0 https://example.com"));
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "show", "wr_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\"model\": \"websocket_connection\""));
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "show", "gc_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\"model\": \"grpc_connection\""));
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "body", "wr_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout("hello\nworld\n");
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "body", "gr_conn"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout("{\"message\":\"hi\"}\n");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "delete", "wr_conn", "--yes"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Deleted 1 responses for request wr_conn"));
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "delete", "gr_conn", "--yes"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Deleted 1 responses for request gr_conn"));
|
||||
let query_manager = query_manager(data_dir);
|
||||
assert!(query_manager.connect().get_websocket_connection("wc_conn").is_err());
|
||||
assert!(query_manager.connect().get_grpc_connection("gc_conn").is_err());
|
||||
}
|
||||
|
||||
/// `response list <workspace>` and `response delete <workspace>` cover HTTP responses and
|
||||
/// gRPC and WebSocket connections together, newest first, with `--limit` applied to the
|
||||
/// merged history.
|
||||
#[test]
|
||||
fn workspace_response_commands_cover_every_kind() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
seed_request(data_dir, "wk_test", "rq_mixed");
|
||||
seed_grpc_request(data_dir, "wk_test", "gr_mixed");
|
||||
seed_websocket_request(data_dir, "wk_test", "wr_mixed");
|
||||
|
||||
let at = |second: u32| format!("2026-01-01T00:00:{second:02}");
|
||||
let (seed_db, blob_manager, _rx) =
|
||||
yaak_models::init_standalone(data_dir.join("db.sqlite"), data_dir.join("blobs.sqlite"))
|
||||
.expect("open database");
|
||||
seed_db
|
||||
.with_tx(|tx| {
|
||||
let source = &UpdateSource::Sync;
|
||||
tx.upsert_http_response(
|
||||
&serde_json::from_value::<HttpResponse>(json!({
|
||||
"id": "rs_oldest", "workspaceId": "wk_test", "requestId": "rq_mixed",
|
||||
"createdAt": at(1), "updatedAt": at(1), "status": 200,
|
||||
"url": "https://example.com/oldest",
|
||||
}))
|
||||
.unwrap(),
|
||||
source,
|
||||
&blob_manager,
|
||||
)?;
|
||||
tx.upsert_websocket_connection(
|
||||
&serde_json::from_value::<WebsocketConnection>(json!({
|
||||
"id": "wc_middle", "workspaceId": "wk_test", "requestId": "wr_mixed",
|
||||
"createdAt": at(2), "updatedAt": at(2), "status": 101,
|
||||
"url": "wss://example.com/socket",
|
||||
}))
|
||||
.unwrap(),
|
||||
source,
|
||||
)?;
|
||||
tx.upsert_grpc_connection(
|
||||
&serde_json::from_value::<GrpcConnection>(json!({
|
||||
"id": "gc_newest", "workspaceId": "wk_test", "requestId": "gr_mixed",
|
||||
"createdAt": at(3), "updatedAt": at(3), "url": "https://example.com",
|
||||
}))
|
||||
.unwrap(),
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, yaak_models::error::Error>(())
|
||||
})
|
||||
.expect("seed history");
|
||||
drop(seed_db);
|
||||
|
||||
let listed = cli_cmd(data_dir).args(["response", "list", "wk_test"]).assert().success();
|
||||
let ids = String::from_utf8_lossy(&listed.get_output().stdout)
|
||||
.lines()
|
||||
.map(|l| l.split_whitespace().next().unwrap_or_default().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, ["gc_newest", "wc_middle", "rs_oldest"]);
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "list", "wk_test", "--limit", "2"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("gc_newest"))
|
||||
.stdout(contains("wc_middle"))
|
||||
.stdout(contains("rs_oldest").not());
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["response", "delete", "wk_test", "--yes"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Deleted 3 responses for workspace wk_test"));
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert!(db.list_http_responses("wk_test", None).unwrap().is_empty());
|
||||
assert!(db.list_grpc_connections("wk_test").unwrap().is_empty());
|
||||
assert!(db.list_websocket_connections("wk_test").unwrap().is_empty());
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use common::http_server::TestHttpServer;
|
||||
use common::{cli_cmd, query_manager, seed_folder, seed_workspace};
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
@@ -48,3 +48,60 @@ fn top_level_send_unknown_id_fails_with_clear_error() {
|
||||
.code(1)
|
||||
.stderr(contains("Could not resolve ID 'does_not_exist' as request, folder, or workspace"));
|
||||
}
|
||||
|
||||
/// QA sweep bug 5: `yaak send <folder>` with a gRPC and a WebSocket request in the folder
|
||||
/// reported them as failures and exited 1, though every HTTP request succeeded.
|
||||
#[test]
|
||||
fn folder_send_skips_grpc_and_websocket_requests() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
seed_workspace(data_dir, "wk_test");
|
||||
seed_folder(data_dir, "wk_test", "fl_test");
|
||||
|
||||
let server = TestHttpServer::spawn_ok("mixed folder");
|
||||
query_manager(data_dir)
|
||||
.with_tx(|tx| {
|
||||
let source = &UpdateSource::Sync;
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_mixed".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
name: "HTTP One".to_string(),
|
||||
url: server.url.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
tx.upsert_grpc_request(
|
||||
&GrpcRequest {
|
||||
id: "gr_mixed".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
name: "Greeter".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
tx.upsert_websocket_request(
|
||||
&WebsocketRequest {
|
||||
id: "wr_mixed".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
name: "Socket".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed folder requests");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args(["send", "fl_test"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("mixed folder"))
|
||||
.stdout(contains("Send summary: 1 succeeded, 0 failed, 2 skipped"))
|
||||
.stdout(contains("gr_mixed - gRPC Greeter"))
|
||||
.stdout(contains("wr_mixed - WebSocket Socket"));
|
||||
}
|
||||
@@ -66,6 +66,27 @@ impl<H: McpHost> YaakMcpServer<H> {
|
||||
))
|
||||
}
|
||||
|
||||
/// The HTTP request `id` names, or an error that says why there is none. These tools
|
||||
/// handle HTTP requests only, so a gRPC or WebSocket ID is named as such.
|
||||
fn require_http_request(&self, id: &str) -> HostResult<HttpRequest> {
|
||||
if let Some(request) = self.host.get_http_request(id)? {
|
||||
return Ok(request);
|
||||
}
|
||||
let other_type = if id.starts_with("gr_") {
|
||||
Some("a gRPC request")
|
||||
} else if id.starts_with("wr_") {
|
||||
Some("a WebSocket request")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Err(match other_type {
|
||||
Some(kind) => format!(
|
||||
"{id} is {kind}, and these tools only work with HTTP requests. Use the Yaak app or the `yaak request` CLI commands for it."
|
||||
),
|
||||
None => format!("HTTP request with ID {id} not found"),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_folder(&self, workspace_id: &str, id: &str) -> HostResult<Option<Folder>> {
|
||||
Ok(self.host.list_folders(workspace_id)?.into_iter().find(|f| f.id == id))
|
||||
}
|
||||
@@ -124,7 +145,7 @@ impl<H: McpHost> YaakMcpServer<H> {
|
||||
Parameters(p): Parameters<GetHttpRequestParams>,
|
||||
) -> ToolResult {
|
||||
self.resolve_workspace(p.workspace_id.as_deref())?;
|
||||
to_json(&self.host.get_http_request(&p.id)?)
|
||||
to_json(&self.require_http_request(&p.id)?)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -137,10 +158,7 @@ impl<H: McpHost> YaakMcpServer<H> {
|
||||
Parameters(p): Parameters<SendHttpRequestParams>,
|
||||
) -> ToolResult {
|
||||
let workspace = self.resolve_workspace(p.workspace_id.as_deref())?;
|
||||
let request = self
|
||||
.host
|
||||
.get_http_request(&p.id)?
|
||||
.ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?;
|
||||
let request = self.require_http_request(&p.id)?;
|
||||
let response =
|
||||
self.host.send_http_request(&workspace.id, request, p.environment_id).await?;
|
||||
|
||||
@@ -182,10 +200,7 @@ impl<H: McpHost> YaakMcpServer<H> {
|
||||
Parameters(p): Parameters<UpdateHttpRequestParams>,
|
||||
) -> ToolResult {
|
||||
self.resolve_workspace(p.workspace_id.as_deref())?;
|
||||
let existing = self
|
||||
.host
|
||||
.get_http_request(&p.id)?
|
||||
.ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?;
|
||||
let existing = self.require_http_request(&p.id)?;
|
||||
let request: HttpRequest = merge(&existing, given_fields(&p))?;
|
||||
to_json(&self.host.upsert_http_request(&request)?)
|
||||
}
|
||||
@@ -199,6 +214,8 @@ impl<H: McpHost> YaakMcpServer<H> {
|
||||
&self,
|
||||
Parameters(p): Parameters<DeleteHttpRequestParams>,
|
||||
) -> ToolResult {
|
||||
// Checked first so a gRPC or WebSocket ID gets an answer, not a missing-row error
|
||||
self.require_http_request(&p.id)?;
|
||||
let request = self.host.delete_http_request(&p.id)?;
|
||||
Ok(format!("Deleted: {} ({})", request.name, request.id))
|
||||
}
|
||||
|
||||
+866
-100
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user