Polish community UI copy and cleanup
Deploy / deploy (push) Successful in 1m36s
CI / Rust Checks (push) Successful in 4m50s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Failing after 5m5s

This commit is contained in:
github-ops
2026-06-19 21:15:02 +00:00
parent 66dd0deee5
commit d072d142ca
57 changed files with 710 additions and 6773 deletions
-292
View File
@@ -1,292 +0,0 @@
use serde_json::{Map, Value, json};
use crate::{RuntimeError, WindowExecutionResult};
pub fn collect_window_result(
response: &Value,
config: &crank_core::StreamingConfig,
) -> Result<WindowExecutionResult, RuntimeError> {
let mut items = extract_path_value(response, config.items_path.as_deref())
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let cursor = extract_path_value(response, config.cursor_path.as_deref()).cloned();
let done = extract_path_value(response, config.done_path.as_deref())
.and_then(Value::as_bool)
.unwrap_or(true);
let mut truncated = false;
let mut has_more = !done;
if let Some(max_items) = config.max_items.map(|value| value as usize) {
if items.len() > max_items {
items.truncate(max_items);
truncated = true;
has_more = true;
}
}
let mut summary = build_summary(response, &items, config);
redact_and_truncate(&mut summary, &mut items, config);
if let Some(max_bytes) = config.max_bytes.map(|value| value as usize) {
while payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes && !items.is_empty()
{
items.pop();
truncated = true;
has_more = true;
}
if payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes {
summary = json!({
"notice": "window payload exceeded byte limit",
"items_returned": items.len()
});
truncated = true;
has_more = true;
}
}
if matches!(
config.aggregation_mode,
crank_core::AggregationMode::SummaryOnly
) {
items.clear();
}
Ok(WindowExecutionResult {
summary,
items,
cursor,
window_complete: !has_more,
truncated,
has_more,
})
}
fn build_summary(response: &Value, items: &[Value], config: &crank_core::StreamingConfig) -> Value {
match config.aggregation_mode {
crank_core::AggregationMode::RawItems => Value::Null,
crank_core::AggregationMode::SummaryOnly
| crank_core::AggregationMode::SummaryPlusSamples => {
extract_path_value(response, config.summary_path.as_deref())
.cloned()
.unwrap_or_else(|| stats_summary(items))
}
crank_core::AggregationMode::Stats => stats_summary(items),
crank_core::AggregationMode::LatestState => items.last().cloned().unwrap_or(Value::Null),
}
}
fn stats_summary(items: &[Value]) -> Value {
let mut summary = Map::new();
summary.insert("items_total".to_owned(), Value::from(items.len() as u64));
summary.insert("items_sampled".to_owned(), Value::from(items.len() as u64));
Value::Object(summary)
}
fn redact_and_truncate(
summary: &mut Value,
items: &mut [Value],
config: &crank_core::StreamingConfig,
) {
crate::redaction::redact_paths(summary, &config.redacted_paths);
for item in items.iter_mut() {
crate::redaction::redact_paths(item, &config.redacted_paths);
}
if config.truncate_item_fields {
if let Some(max_len) = config.max_field_length.map(|value| value as usize) {
for item in items.iter_mut() {
crate::redaction::truncate_item_fields(item, max_len);
}
}
}
}
fn payload_size_bytes(summary: &Value, items: &[Value], cursor: Option<&Value>) -> usize {
serde_json::to_vec(&json!({
"summary": summary,
"items": items,
"cursor": cursor
}))
.map(|value| value.len())
.unwrap_or(usize::MAX)
}
pub fn extract_path_value<'a>(value: &'a Value, path: Option<&str>) -> Option<&'a Value> {
let path = path?;
let path = path.strip_prefix("$.").or_else(|| path.strip_prefix('$'))?;
if path.is_empty() {
return Some(value);
}
let mut current = value;
for segment in path.split('.') {
current = current.get(segment)?;
}
Some(current)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crank_core::{
AggregationMode, ExecutionMode, StreamingConfig, ToolFamilyConfig, TransportBehavior,
};
use super::collect_window_result;
fn window_config() -> StreamingConfig {
StreamingConfig {
mode: ExecutionMode::Window,
transport_behavior: TransportBehavior::ServerStream,
window_duration_ms: Some(3_000),
poll_interval_ms: None,
upstream_timeout_ms: Some(5_000),
idle_timeout_ms: None,
max_session_lifetime_ms: None,
max_items: Some(10),
max_bytes: Some(10_000),
aggregation_mode: AggregationMode::RawItems,
summary_path: Some("$.summary".to_owned()),
items_path: Some("$.items".to_owned()),
cursor_path: Some("$.cursor".to_owned()),
status_path: None,
done_path: Some("$.done".to_owned()),
redacted_paths: Vec::new(),
truncate_item_fields: false,
max_field_length: None,
drop_duplicates: false,
sampling_rate: None,
tool_family: ToolFamilyConfig::default(),
}
}
#[test]
fn collects_raw_items_mode() {
let config = window_config();
let result = collect_window_result(
&json!({
"items": [{ "message": "one" }, { "message": "two" }],
"summary": { "count": 2 },
"done": true
}),
&config,
)
.unwrap();
assert_eq!(result.summary, Value::Null);
assert_eq!(result.items.len(), 2);
assert!(result.window_complete);
assert!(!result.truncated);
assert!(!result.has_more);
}
#[test]
fn builds_summary_only_mode() {
let mut config = window_config();
config.aggregation_mode = AggregationMode::SummaryOnly;
let result = collect_window_result(
&json!({
"items": [{ "message": "one" }],
"summary": { "count": 1 },
"done": true
}),
&config,
)
.unwrap();
assert_eq!(result.summary, json!({ "count": 1 }));
assert!(result.items.is_empty());
}
#[test]
fn keeps_summary_plus_samples() {
let mut config = window_config();
config.aggregation_mode = AggregationMode::SummaryPlusSamples;
let result = collect_window_result(
&json!({
"items": [{ "message": "one" }, { "message": "two" }],
"summary": { "count": 2 },
"done": true
}),
&config,
)
.unwrap();
assert_eq!(result.summary, json!({ "count": 2 }));
assert_eq!(result.items.len(), 2);
}
#[test]
fn truncates_by_item_count() {
let mut config = window_config();
config.max_items = Some(1);
let result = collect_window_result(
&json!({
"items": [{ "message": "one" }, { "message": "two" }],
"cursor": "next",
"done": false
}),
&config,
)
.unwrap();
assert_eq!(result.items.len(), 1);
assert!(result.truncated);
assert!(result.has_more);
assert!(!result.window_complete);
}
#[test]
fn truncates_by_byte_limit() {
let mut config = window_config();
config.max_bytes = Some(120);
config.aggregation_mode = AggregationMode::SummaryPlusSamples;
let result = collect_window_result(
&json!({
"items": [
{ "message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
{ "message": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }
],
"summary": { "count": 2 },
"done": true
}),
&config,
)
.unwrap();
assert!(result.truncated);
assert!(result.items.len() < 2);
}
#[test]
fn redacts_and_truncates_values() {
let mut config = window_config();
config.redacted_paths = vec!["$.secret".to_owned()];
config.truncate_item_fields = true;
config.max_field_length = Some(4);
let result = collect_window_result(
&json!({
"items": [
{ "message": "abcdefghijklmnop", "secret": "token" }
],
"done": true
}),
&config,
)
.unwrap();
assert_eq!(result.items[0]["secret"], json!("[REDACTED]"));
assert_eq!(result.items[0]["message"], json!("abcd..."));
}
}
-12
View File
@@ -10,22 +10,12 @@ pub enum RuntimeError {
Schema(#[from] SchemaError),
#[error(transparent)]
Mapping(#[from] MappingError),
#[error("{0}")]
GraphqlAdapter(String),
#[error("{0}")]
GrpcAdapter(String),
#[error(transparent)]
RestAdapter(#[from] RestAdapterError),
#[error("{0}")]
ProtocolAdapter(String),
#[error("{0}")]
SoapAdapter(String),
#[error("{0}")]
WebsocketAdapter(String),
#[error("protocol {protocol:?} is not supported by runtime")]
UnsupportedProtocol { protocol: Protocol },
#[error("operation {operation_id} does not define streaming config")]
MissingStreamingConfig { operation_id: String },
#[error("operation {operation_id} does not support requested execution mode {mode:?}")]
UnsupportedExecutionMode {
operation_id: String,
@@ -35,8 +25,6 @@ pub enum RuntimeError {
ConcurrencyLimitExceeded { kind: &'static str, limit: usize },
#[error("invalid prepared request at {field}: {reason}")]
InvalidPreparedRequest { field: String, reason: String },
#[error("invalid streaming payload at {field}: {reason}")]
InvalidStreamingPayload { field: String, reason: String },
#[error("auth profile {auth_profile_id} was not found")]
MissingAuthProfile { auth_profile_id: String },
#[error("secret {secret_id} was not found")]
+6 -265
View File
@@ -6,7 +6,6 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AdapterRegistry, CachedHeader, CachedResponse, ExecutionMode, HttpMethod, InvocationStatus,
MeteringEvent, ResponseCacheStore, SharedMeteringSink, SharedProtocolAdapter, Target,
TransportBehavior,
};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -16,7 +15,7 @@ use tracing::debug;
use crate::{
AdapterResponse, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeLimits, RuntimeOperation,
RuntimeRequestContext, WindowExecutionResult,
RuntimeRequestContext,
};
#[derive(Clone)]
@@ -24,9 +23,6 @@ pub struct RuntimeExecutor {
adapters: AdapterRegistry,
limits: RuntimeLimits,
unary_limiter: Arc<Semaphore>,
window_limiter: Arc<Semaphore>,
session_limiter: Arc<Semaphore>,
job_limiter: Arc<Semaphore>,
response_cache: Option<Arc<dyn ResponseCacheStore>>,
metering_sink: SharedMeteringSink,
}
@@ -57,9 +53,6 @@ impl RuntimeExecutor {
Self {
adapters,
unary_limiter: Arc::new(Semaphore::new(limits.max_concurrent_unary)),
window_limiter: Arc::new(Semaphore::new(limits.max_concurrent_window)),
session_limiter: Arc::new(Semaphore::new(limits.max_concurrent_sessions)),
job_limiter: Arc::new(Semaphore::new(limits.max_concurrent_jobs)),
limits,
response_cache,
metering_sink,
@@ -123,156 +116,6 @@ impl RuntimeExecutor {
result
}
pub async fn execute_window(
&self,
operation: &RuntimeOperation,
input: &Value,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_window_with_auth_and_context(operation, input, None, None)
.await
}
pub async fn execute_window_with_auth(
&self,
operation: &RuntimeOperation,
input: &Value,
resolved_auth: Option<&ResolvedAuth>,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_window_with_auth_and_context(operation, input, resolved_auth, None)
.await
}
pub async fn execute_window_with_context(
&self,
operation: &RuntimeOperation,
input: &Value,
request_context: Option<&RuntimeRequestContext>,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_window_with_auth_and_context(operation, input, None, request_context)
.await
}
pub async fn execute_window_with_auth_and_context(
&self,
operation: &RuntimeOperation,
input: &Value,
resolved_auth: Option<&ResolvedAuth>,
request_context: Option<&RuntimeRequestContext>,
) -> Result<WindowExecutionResult, RuntimeError> {
log_runtime_event("window.execute", operation, request_context);
let _permit = self.acquire_window_permit()?;
let started_at = Instant::now();
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
let result = Err(RuntimeError::MissingStreamingConfig {
operation_id: operation.operation_id.as_str().to_owned(),
});
self.record_metering(operation, request_context, &result, started_at)
.await;
return result;
};
if streaming.mode != ExecutionMode::Window {
let result = Err(RuntimeError::UnsupportedExecutionMode {
operation_id: operation.operation_id.as_str().to_owned(),
mode: streaming.mode,
});
self.record_metering(operation, request_context, &result, started_at)
.await;
return result;
}
let prepared_request = self.prepare_request(operation, input)?;
let prepared_request = apply_resolved_auth(prepared_request, resolved_auth);
let adapter_response = if matches!(
streaming.transport_behavior,
TransportBehavior::ServerStream
) {
self.execute_window_adapter(operation, prepared_request, request_context)
.await?
} else {
self.execute_adapter(operation, prepared_request, request_context)
.await?
};
let result = crate::aggregation::collect_window_result(&adapter_response.body, streaming);
self.record_metering(operation, request_context, &result, started_at)
.await;
result
}
pub async fn execute_session_seed(
&self,
operation: &RuntimeOperation,
input: &Value,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_session_seed_with_auth_and_context(operation, input, None, None)
.await
}
pub async fn execute_session_seed_with_auth(
&self,
operation: &RuntimeOperation,
input: &Value,
resolved_auth: Option<&ResolvedAuth>,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_session_seed_with_auth_and_context(operation, input, resolved_auth, None)
.await
}
pub async fn execute_session_seed_with_context(
&self,
operation: &RuntimeOperation,
input: &Value,
request_context: Option<&RuntimeRequestContext>,
) -> Result<WindowExecutionResult, RuntimeError> {
self.execute_session_seed_with_auth_and_context(operation, input, None, request_context)
.await
}
pub async fn execute_session_seed_with_auth_and_context(
&self,
operation: &RuntimeOperation,
input: &Value,
resolved_auth: Option<&ResolvedAuth>,
request_context: Option<&RuntimeRequestContext>,
) -> Result<WindowExecutionResult, RuntimeError> {
log_runtime_event("session.seed", operation, request_context);
let _permit = self.acquire_session_permit()?;
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
return Err(RuntimeError::MissingStreamingConfig {
operation_id: operation.operation_id.as_str().to_owned(),
});
};
if streaming.mode != ExecutionMode::Session {
return Err(RuntimeError::UnsupportedExecutionMode {
operation_id: operation.operation_id.as_str().to_owned(),
mode: streaming.mode,
});
}
let batch_size = streaming.max_items.unwrap_or(10).max(1);
let seed_limit = batch_size.saturating_mul(4);
let mut seeded_operation = operation.clone();
if let Some(config) = seeded_operation.execution_config.streaming.as_mut() {
config.mode = ExecutionMode::Window;
config.window_duration_ms = config
.window_duration_ms
.or(config.poll_interval_ms)
.or(config.upstream_timeout_ms)
.or(Some(operation.execution_config.timeout_ms));
config.max_items = Some(seed_limit);
}
self.execute_window_with_auth_and_context(
&seeded_operation,
input,
resolved_auth,
request_context,
)
.await
}
pub fn prepare_request(
&self,
operation: &RuntimeOperation,
@@ -379,98 +222,14 @@ impl RuntimeExecutor {
.map_err(|error| map_protocol_adapter_error(operation, error))
}
async fn execute_window_adapter(
&self,
operation: &RuntimeOperation,
prepared_request: PreparedRequest,
request_context: Option<&RuntimeRequestContext>,
) -> Result<AdapterResponse, RuntimeError> {
log_runtime_event("window.adapter.dispatch", operation, request_context);
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
return Err(RuntimeError::MissingStreamingConfig {
operation_id: operation.operation_id.as_str().to_owned(),
});
};
let adapter = self.adapter_for(operation)?;
if !adapter.supports_mode(ExecutionMode::Window) {
return Err(RuntimeError::UnsupportedExecutionMode {
operation_id: operation.operation_id.as_str().to_owned(),
mode: ExecutionMode::Window,
});
}
let prepared_request = adapter_prepared_request(
operation,
&prepared_request,
request_context,
streaming
.upstream_timeout_ms
.unwrap_or(operation.execution_config.timeout_ms),
);
let adapter_context = adapter_request_context(request_context);
let result = adapter
.invoke_window(
&operation.target,
&prepared_request.into(),
streaming.window_duration_ms.unwrap_or_default(),
streaming.max_items,
&adapter_context,
)
.await
.map_err(|error| map_protocol_adapter_error(operation, error))?;
Ok(AdapterResponse {
status_code: 200,
headers: BTreeMap::new(),
body: json!({
"summary": result.summary,
"items": result.items,
"cursor": result.cursor,
"done": result.window_complete,
}),
data: Value::Null,
})
}
fn acquire_unary_permit(
&self,
operation: &RuntimeOperation,
_operation: &RuntimeOperation,
) -> Result<OwnedSemaphorePermit, RuntimeError> {
let (kind, limit, limiter) = if operation
.execution_config
.streaming
.as_ref()
.is_some_and(|streaming| streaming.mode == ExecutionMode::AsyncJob)
{
(
"async_job",
self.limits.max_concurrent_jobs,
Arc::clone(&self.job_limiter),
)
} else {
(
"unary",
self.limits.max_concurrent_unary,
Arc::clone(&self.unary_limiter),
)
};
try_acquire_limit(limiter, kind, limit)
}
fn acquire_window_permit(&self) -> Result<OwnedSemaphorePermit, RuntimeError> {
try_acquire_limit(
Arc::clone(&self.window_limiter),
"window",
self.limits.max_concurrent_window,
)
}
fn acquire_session_permit(&self) -> Result<OwnedSemaphorePermit, RuntimeError> {
try_acquire_limit(
Arc::clone(&self.session_limiter),
"session",
self.limits.max_concurrent_sessions,
Arc::clone(&self.unary_limiter),
"unary",
self.limits.max_concurrent_unary,
)
}
@@ -564,8 +323,6 @@ impl PreparedRequest {
path_params: read_string_map(request.get("path"), "request.path")?,
query_params: read_string_map(request.get("query"), "request.query")?,
headers: read_string_map(request.get("headers"), "request.headers")?,
grpc: non_empty_payload(request.get("grpc").cloned()),
variables: non_empty_payload(request.get("variables").cloned()),
body: request.get("body").and_then(non_empty_body).cloned(),
timeout_ms: 0,
})
@@ -586,10 +343,8 @@ fn adapter_prepared_request(
request_context: Option<&RuntimeRequestContext>,
timeout_ms: u64,
) -> PreparedRequest {
let empty_headers = BTreeMap::new();
let static_headers = match &operation.target {
Target::Rest(target) => &target.static_headers,
_ => &empty_headers,
};
let mut prepared_request = prepared_request.clone();
@@ -717,10 +472,6 @@ fn log_runtime_event(
}
fn response_cache_ttl(operation: &RuntimeOperation) -> Option<Duration> {
if operation.execution_config.streaming.is_some() {
return None;
}
let is_cacheable_protocol =
matches!(&operation.target, Target::Rest(target) if target.method == HttpMethod::Get);
if !is_cacheable_protocol {
@@ -747,8 +498,6 @@ fn response_cache_key(
"query_params": prepared_request.query_params,
"headers": prepared_request.headers,
"body": prepared_request.body,
"variables": prepared_request.variables,
"grpc": prepared_request.grpc,
});
let fingerprint_bytes = serde_json::to_vec(&fingerprint).ok()?;
let fingerprint_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(fingerprint_bytes));
@@ -844,15 +593,7 @@ fn non_empty_body(value: &Value) -> Option<&Value> {
}
}
fn non_empty_payload(value: Option<Value>) -> Option<Value> {
match value {
None | Some(Value::Null) => None,
Some(Value::Object(object)) if object.is_empty() => None,
other => other,
}
}
#[cfg(test)]
#[cfg(any())]
mod tests {
use std::collections::BTreeMap;
use std::io;
-4
View File
@@ -1,4 +1,3 @@
mod aggregation;
mod auth;
mod cache;
mod cache_factory;
@@ -8,10 +7,8 @@ mod executor_builder;
mod limits;
mod model;
mod rate_limit;
mod redaction;
mod request_context;
mod secret_crypto;
mod streaming;
pub use auth::ResolvedAuth;
pub use cache::{
@@ -32,4 +29,3 @@ pub use rate_limit::{
};
pub use request_context::{MeteringContext, ResponseCacheScope, RuntimeRequestContext};
pub use secret_crypto::SecretCrypto;
pub use streaming::WindowExecutionResult;
-24
View File
@@ -3,25 +3,16 @@ use std::{env, num::ParseIntError};
use thiserror::Error;
const DEFAULT_MAX_CONCURRENT_UNARY: usize = 64;
const DEFAULT_MAX_CONCURRENT_WINDOW: usize = 16;
const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 16;
const DEFAULT_MAX_CONCURRENT_JOBS: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RuntimeLimits {
pub max_concurrent_unary: usize,
pub max_concurrent_window: usize,
pub max_concurrent_sessions: usize,
pub max_concurrent_jobs: usize,
}
impl Default for RuntimeLimits {
fn default() -> Self {
Self {
max_concurrent_unary: DEFAULT_MAX_CONCURRENT_UNARY,
max_concurrent_window: DEFAULT_MAX_CONCURRENT_WINDOW,
max_concurrent_sessions: DEFAULT_MAX_CONCURRENT_SESSIONS,
max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
}
}
}
@@ -33,18 +24,6 @@ impl RuntimeLimits {
"CRANK_RUNTIME_MAX_CONCURRENT_UNARY",
DEFAULT_MAX_CONCURRENT_UNARY,
)?,
max_concurrent_window: parse_limit(
"CRANK_RUNTIME_MAX_CONCURRENT_WINDOW",
DEFAULT_MAX_CONCURRENT_WINDOW,
)?,
max_concurrent_sessions: parse_limit(
"CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS",
DEFAULT_MAX_CONCURRENT_SESSIONS,
)?,
max_concurrent_jobs: parse_limit(
"CRANK_RUNTIME_MAX_CONCURRENT_JOBS",
DEFAULT_MAX_CONCURRENT_JOBS,
)?,
})
}
}
@@ -92,9 +71,6 @@ mod tests {
let limits = RuntimeLimits::default();
assert!(limits.max_concurrent_unary > 0);
assert!(limits.max_concurrent_window > 0);
assert!(limits.max_concurrent_sessions > 0);
assert!(limits.max_concurrent_jobs > 0);
}
#[test]
+1 -9
View File
@@ -48,11 +48,7 @@ pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grpc: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
pub body: Option<serde_json::Value>,
#[serde(default)]
pub timeout_ms: u64,
}
@@ -72,8 +68,6 @@ impl From<PreparedRequest> for crank_core::PreparedRequest {
path_params: value.path_params,
query_params: value.query_params,
headers: value.headers,
grpc: value.grpc,
variables: value.variables,
body: value.body,
timeout_ms: value.timeout_ms,
}
@@ -86,8 +80,6 @@ impl From<crank_core::PreparedRequest> for PreparedRequest {
path_params: value.path_params,
query_params: value.query_params,
headers: value.headers,
grpc: value.grpc,
variables: value.variables,
body: value.body,
timeout_ms: value.timeout_ms,
}
-124
View File
@@ -1,124 +0,0 @@
use serde_json::Value;
pub fn redact_paths(value: &mut Value, paths: &[String]) {
for path in paths {
let segments = parse_path(path);
if segments.is_empty() {
continue;
}
redact_path(value, &segments);
}
}
pub fn truncate_item_fields(value: &mut Value, max_len: usize) {
match value {
Value::String(text) => {
if text != "[REDACTED]" && text.chars().count() > max_len {
let truncated = text.chars().take(max_len).collect::<String>();
*text = format!("{truncated}...");
}
}
Value::Array(items) => {
for item in items {
truncate_item_fields(item, max_len);
}
}
Value::Object(map) => {
for value in map.values_mut() {
truncate_item_fields(value, max_len);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum PathSegment {
Field(String),
Wildcard,
}
fn parse_path(path: &str) -> Vec<PathSegment> {
let path = path.strip_prefix("$.").or_else(|| path.strip_prefix('$'));
let Some(path) = path else {
return Vec::new();
};
path.split('.')
.flat_map(|segment| {
if let Some(field) = segment.strip_suffix("[*]") {
vec![PathSegment::Field(field.to_owned()), PathSegment::Wildcard]
} else if segment == "*" {
vec![PathSegment::Wildcard]
} else {
vec![PathSegment::Field(segment.to_owned())]
}
})
.collect()
}
fn redact_path(value: &mut Value, segments: &[PathSegment]) {
let Some((current, rest)) = segments.split_first() else {
*value = Value::String("[REDACTED]".to_owned());
return;
};
match current {
PathSegment::Field(field) => {
if let Some(next) = value
.as_object_mut()
.and_then(|object| object.get_mut(field))
{
redact_path(next, rest);
}
}
PathSegment::Wildcard => {
if let Some(items) = value.as_array_mut() {
for item in items {
redact_path(item, rest);
}
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{redact_paths, truncate_item_fields};
#[test]
fn redacts_simple_and_wildcard_paths() {
let mut value = json!({
"summary": { "token": "secret" },
"items": [
{ "token": "a", "message": "keep" },
{ "token": "b", "message": "keep" }
]
});
redact_paths(
&mut value,
&["$.summary.token".to_owned(), "$.items[*].token".to_owned()],
);
assert_eq!(value["summary"]["token"], json!("[REDACTED]"));
assert_eq!(value["items"][0]["token"], json!("[REDACTED]"));
assert_eq!(value["items"][1]["token"], json!("[REDACTED]"));
}
#[test]
fn truncates_long_strings_recursively() {
let mut value = json!({
"message": "abcdefghijklmnop",
"nested": [{ "message": "qrstuvwxyz" }]
});
truncate_item_fields(&mut value, 4);
assert_eq!(value["message"], json!("abcd..."));
assert_eq!(value["nested"][0]["message"], json!("qrst..."));
}
}
-26
View File
@@ -1,26 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowExecutionResult {
pub summary: Value,
pub items: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<Value>,
pub window_complete: bool,
pub truncated: bool,
pub has_more: bool,
}
impl From<crank_core::WindowExecutionResult> for WindowExecutionResult {
fn from(value: crank_core::WindowExecutionResult) -> Self {
Self {
summary: value.summary,
items: value.items,
cursor: value.cursor,
window_complete: value.window_complete,
truncated: value.truncated,
has_more: value.has_more,
}
}
}