mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-26 13:24:48 +02:00
Add response access and stop mangling non-HTTP request payloads
Stored responses were unreachable from the CLI even though the model layer has had list/get/delete all along. After a send, an agent could only see the body it just streamed: no status, no timing, no history. `yaak response list|show|body|delete` closes that. `show` and `body` accept a request ID as shorthand for its most recent response, which is the common case, and `show` returns status, reason, timing, headers, the final URL, and any transport error as JSON. `request create` also accepted payloads for request types it cannot create. A gRPC-shaped payload deserialized into an HttpRequest with the unknown fields dropped, so the gRPC method name landed in `method`, `service` vanished, and the result looked like a successful create. It now rejects a non-`http_request` `model`, and any field that is not part of the HTTP request schema, pointing at the app instead. `request update` had the same silent-drop behavior and gets the same check. The skill now points at `response show` rather than teaching agents to grep verbose send output for the status line.
This commit is contained in:
@@ -110,18 +110,26 @@ committed `yaak export` plus `--data-dir ./.yaak` gives a runnable suite in CI.
|
|||||||
|
|
||||||
## Reading results
|
## Reading results
|
||||||
|
|
||||||
A plain send writes only the response body to stdout. Add `-v` for the request
|
A plain send writes only the response body to stdout. Yaak also stores every
|
||||||
and response metadata, where lines are prefixed `*`, `>`, and `<`:
|
response, so the reliable way to see what happened is to ask afterwards rather
|
||||||
|
than to parse the send output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yaak -v request send rq_abc123 2>&1 | grep '^< HTTP'
|
yaak response show rq_abc123 # latest response for a request, as JSON
|
||||||
|
yaak response list rq_abc123 # its history, newest first
|
||||||
|
yaak response body rq_abc123 # just the body
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`response show` gives status, reason, timing, headers, the final URL, and any
|
||||||
|
transport error. Pass a response ID for a specific one. `-v` on a send prints
|
||||||
|
the same information live, prefixed `*`, `>`, and `<`, but interleaves it with
|
||||||
|
the body on stdout, so prefer `response show` when you need to act on the result.
|
||||||
|
|
||||||
Exit code 1 means the send did not complete: an unresolved template variable, an
|
Exit code 1 means the send did not complete: an unresolved template variable, an
|
||||||
unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like
|
unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like
|
||||||
`curl`, a 404 or 500 exits 0, and a folder of requests that all return 500
|
`curl`, a 404 or 500 exits 0, and a folder of requests that all return 500
|
||||||
reports success. Never tell the user an API is healthy based on a clean exit;
|
reports success. Never tell the user an API is healthy based on a clean exit;
|
||||||
check the status yourself with `-v`.
|
check the status.
|
||||||
|
|
||||||
## Execution rules
|
## Execution rules
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,50 @@ pub enum Commands {
|
|||||||
/// Template function commands
|
/// Template function commands
|
||||||
#[command(alias = "func")]
|
#[command(alias = "func")]
|
||||||
TemplateFunction(TemplateFunctionArgs),
|
TemplateFunction(TemplateFunctionArgs),
|
||||||
|
|
||||||
|
/// Response commands
|
||||||
|
Response(ResponseArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ResponseArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: ResponseCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum ResponseCommands {
|
||||||
|
/// List stored responses for a request or workspace
|
||||||
|
List {
|
||||||
|
/// Request or workspace ID (optional when exactly one workspace exists)
|
||||||
|
id: Option<String>,
|
||||||
|
|
||||||
|
/// Maximum number of responses to return, newest first
|
||||||
|
#[arg(long)]
|
||||||
|
limit: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show a response as JSON, including status, timing, and headers
|
||||||
|
Show {
|
||||||
|
/// Response ID, or a request ID to use its most recent response
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Write a stored response body to stdout
|
||||||
|
Body {
|
||||||
|
/// Response ID, or a request ID to use its most recent response
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete stored responses for a request or workspace
|
||||||
|
Delete {
|
||||||
|
/// Request or workspace ID
|
||||||
|
id: String,
|
||||||
|
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[arg(short, long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod folder;
|
|||||||
pub mod import_export;
|
pub mod import_export;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
|
pub mod response;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
pub mod template_function;
|
pub mod template_function;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `request create` only builds HTTP requests, but `request schema` will happily hand
|
||||||
|
/// out gRPC and WebSocket schemas. Without this, a gRPC payload deserializes into an
|
||||||
|
/// 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)
|
||||||
|
&& !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."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(HttpRequest))
|
||||||
|
.ok()
|
||||||
|
.and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned())
|
||||||
|
.map(|properties| properties.keys().cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if known.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let unknown: Vec<&str> =
|
||||||
|
object.keys().filter(|key| !known.contains(*key)).map(String::as_str).collect();
|
||||||
|
|
||||||
|
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.",
|
||||||
|
unknown.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
|
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
|
||||||
if !matches!(request_type, RequestSchemaType::Http) {
|
if !matches!(request_type, RequestSchemaType::Http) {
|
||||||
return;
|
return;
|
||||||
@@ -367,6 +408,7 @@ fn create(
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate_create_id(&payload, "request")?;
|
validate_create_id(&payload, "request")?;
|
||||||
|
reject_non_http_payload(&payload, "request create")?;
|
||||||
let mut request: HttpRequest = serde_json::from_value(payload)
|
let mut request: HttpRequest = serde_json::from_value(payload)
|
||||||
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
|
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
|
||||||
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
|
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
|
||||||
@@ -415,6 +457,7 @@ fn create(
|
|||||||
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
|
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
|
||||||
let patch = parse_required_json(json, json_input, "request update")?;
|
let patch = parse_required_json(json, json_input, "request update")?;
|
||||||
let id = require_id(&patch, "request update")?;
|
let id = require_id(&patch, "request update")?;
|
||||||
|
reject_non_http_payload(&patch, "request update")?;
|
||||||
|
|
||||||
let existing = ctx
|
let existing = ctx
|
||||||
.db()
|
.db()
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
use crate::cli::{ResponseArgs, ResponseCommands};
|
||||||
|
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::util::UpdateSource;
|
||||||
|
|
||||||
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
|
pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
|
||||||
|
let result = match args.command {
|
||||||
|
ResponseCommands::List { id, limit } => list(ctx, id.as_deref(), limit),
|
||||||
|
ResponseCommands::Show { id } => show(ctx, &id),
|
||||||
|
ResponseCommands::Body { id } => body(ctx, &id),
|
||||||
|
ResponseCommands::Delete { id, yes } => delete(ctx, &id, yes),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"))?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("Request {id} has no stored responses yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Could not resolve ID '{id}' as a response or request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if responses.is_empty() {
|
||||||
|
println!("No responses found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for response in responses {
|
||||||
|
let status = match response.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();
|
||||||
|
let summary = match first_line.char_indices().nth(80) {
|
||||||
|
Some((cut, _)) => format!("{}...", &first_line[..cut]),
|
||||||
|
None => first_line.to_string(),
|
||||||
|
};
|
||||||
|
format!("ERROR {summary}")
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let reason = response.status_reason.unwrap_or_default();
|
||||||
|
format!("{} {}", response.status, reason).trim_end().to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("{} - {} {} ({}ms)", response.id, status, response.url, response.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}"))?;
|
||||||
|
println!("{output}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !yes && !confirm_delete("responses for request", id) {
|
||||||
|
println!("Aborted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// The delete helpers return no count, so take one first to report honestly.
|
||||||
|
let count = ctx
|
||||||
|
.db()
|
||||||
|
.list_http_responses_for_request(id, None)
|
||||||
|
.map_err(|e| format!("Failed to list responses: {e}"))?
|
||||||
|
.len();
|
||||||
|
ctx.db()
|
||||||
|
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
|
||||||
|
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||||
|
println!("Deleted {count} responses for request {id}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace_id = resolve_workspace_id(ctx, Some(id), "response delete")?;
|
||||||
|
if !yes && !confirm_delete("responses for workspace", &workspace_id) {
|
||||||
|
println!("Aborted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let count = ctx
|
||||||
|
.db()
|
||||||
|
.list_http_responses(&workspace_id, None)
|
||||||
|
.map_err(|e| format!("Failed to list responses: {e}"))?
|
||||||
|
.len();
|
||||||
|
ctx.db()
|
||||||
|
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||||
|
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||||
|
println!("Deleted {count} responses for workspace {workspace_id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -37,6 +37,12 @@ async fn main() {
|
|||||||
|
|
||||||
let exit_code = match command {
|
let exit_code = match command {
|
||||||
Commands::Agent(args) => commands::agent::run(args),
|
Commands::Agent(args) => commands::agent::run(args),
|
||||||
|
Commands::Response(args) => {
|
||||||
|
let context = CliContext::new(data_dir.clone(), app_id);
|
||||||
|
let exit_code = commands::response::run(&context, args);
|
||||||
|
context.shutdown().await;
|
||||||
|
exit_code
|
||||||
|
}
|
||||||
Commands::TemplateFunction(args) => {
|
Commands::TemplateFunction(args) => {
|
||||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
context.init_plugins(CliExecutionContext::default()).await;
|
context.init_plugins(CliExecutionContext::default()).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user