mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 11:49:50 +08:00
refactor(rules): 规则引擎容错优化,无效规则条目跳过而非中止整个规则集
- header/body rules 的 _are_locally_supported 简化为仅检查是否为数组 - apply 逻辑中遇到格式错误/不支持的规则条目改为 continue 跳过,而非 return false - 允许非字符串 header value,自动序列化为 JSON 字符串 - 宽松处理无效 regex flag,不再拒绝整条规则 fix(gateway): Claude CLI 路由仅检查 bearer 头,不排斥同时携带 x-api-key 的请求 feat(observability): 监控链路候选展示解密 auth_config 的账号标签和 OAuth 计划类型
This commit is contained in:
@@ -185,10 +185,7 @@ pub(super) fn is_claude_cli_request(headers: &http::HeaderMap) -> bool {
|
|||||||
let auth_header = header_value_str(headers, http::header::AUTHORIZATION.as_str())
|
let auth_header = header_value_str(headers, http::header::AUTHORIZATION.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
let has_bearer = auth_header.starts_with("bearer ");
|
auth_header.starts_with("bearer ")
|
||||||
let has_api_key =
|
|
||||||
header_value_str(headers, "x-api-key").is_some_and(|value| !value.trim().is_empty());
|
|
||||||
has_bearer && !has_api_key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_gemini_cli_request(headers: &http::HeaderMap) -> bool {
|
pub(super) fn is_gemini_cli_request(headers: &http::HeaderMap) -> bool {
|
||||||
|
|||||||
@@ -52,6 +52,41 @@ fn classifies_claude_messages_cli_when_bearer_without_api_key() {
|
|||||||
assert!(decision.is_execution_runtime_candidate());
|
assert!(decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_claude_messages_cli_when_bearer_is_present_even_with_api_key() {
|
||||||
|
let headers = headers(&[
|
||||||
|
("authorization", "Bearer token-123"),
|
||||||
|
("x-api-key", "sk-client"),
|
||||||
|
]);
|
||||||
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(decision.route_kind.as_deref(), Some("cli"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("claude:cli")
|
||||||
|
);
|
||||||
|
assert!(decision.is_execution_runtime_candidate());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_claude_messages_chat_when_api_key_without_bearer() {
|
||||||
|
let headers = headers(&[("x-api-key", "sk-client")]);
|
||||||
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(decision.route_kind.as_deref(), Some("chat"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("claude:chat")
|
||||||
|
);
|
||||||
|
assert!(decision.is_execution_runtime_candidate());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_gemini_cli_generate_content_when_x_app_contains_cli() {
|
fn classifies_gemini_cli_generate_content_when_x_app_contains_cli() {
|
||||||
let headers = headers(&[("x-app", "Gemini-CLI")]);
|
let headers = headers(&[("x-app", "Gemini-CLI")]);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use super::super::test_support::{
|
|||||||
request_context, sample_candidate, sample_endpoint, sample_key, sample_provider, sample_usage,
|
request_context, sample_candidate, sample_endpoint, sample_key, sample_provider, sample_usage,
|
||||||
};
|
};
|
||||||
use super::local_monitoring_response;
|
use super::local_monitoring_response;
|
||||||
|
use crate::data::GatewayDataState;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||||
use axum::body::to_bytes;
|
use axum::body::to_bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -76,6 +78,78 @@ async fn admin_monitoring_trace_request_returns_local_payload() {
|
|||||||
assert_eq!(payload["candidates"][0]["status_code"], json!(502));
|
assert_eq!(payload["candidates"][0]["status_code"], json!(502));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_monitoring_trace_request_returns_oauth_account_label_from_auth_config() {
|
||||||
|
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||||
|
sample_candidate(
|
||||||
|
"cand-used",
|
||||||
|
"request-1",
|
||||||
|
0,
|
||||||
|
RequestCandidateStatus::Failed,
|
||||||
|
Some(101),
|
||||||
|
Some(33),
|
||||||
|
Some(502),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let auth_config = json!({
|
||||||
|
"provider_type": "codex",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"plan_type": "plus",
|
||||||
|
"refresh_token": "rt-test"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let oauth_key = sample_key()
|
||||||
|
.with_transport_fields(
|
||||||
|
None,
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
||||||
|
.expect("placeholder should encrypt"),
|
||||||
|
Some(
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, &auth_config)
|
||||||
|
.expect("auth config should encrypt"),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("key transport fields should build");
|
||||||
|
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_provider()],
|
||||||
|
vec![sample_endpoint()],
|
||||||
|
vec![oauth_key],
|
||||||
|
));
|
||||||
|
let data_state = GatewayDataState::with_decision_trace_readers_for_tests(
|
||||||
|
request_candidates,
|
||||||
|
provider_catalog,
|
||||||
|
)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_data_state_for_tests(data_state);
|
||||||
|
let context = request_context(http::Method::GET, "/api/admin/monitoring/trace/request-1");
|
||||||
|
|
||||||
|
let response = local_monitoring_response(&state, &context)
|
||||||
|
.await
|
||||||
|
.expect("handler should not error")
|
||||||
|
.expect("route should be handled locally");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), http::StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read");
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload["candidates"][0]["key_account_label"],
|
||||||
|
json!("[email protected]")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["candidates"][0]["key_oauth_plan_type"],
|
||||||
|
json!("plus")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_monitoring_trace_final_status_prefers_failed_over_stale_pending() {
|
async fn admin_monitoring_trace_final_status_prefers_failed_over_stale_pending() {
|
||||||
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||||
|
|||||||
@@ -6,19 +6,26 @@ use aether_admin::observability::monitoring::{
|
|||||||
admin_monitoring_bad_request_response, admin_monitoring_trace_not_found_response,
|
admin_monitoring_bad_request_response, admin_monitoring_trace_not_found_response,
|
||||||
admin_monitoring_trace_provider_id_from_path, admin_monitoring_trace_request_id_from_path,
|
admin_monitoring_trace_provider_id_from_path, admin_monitoring_trace_request_id_from_path,
|
||||||
build_admin_monitoring_trace_provider_stats_payload_response,
|
build_admin_monitoring_trace_provider_stats_payload_response,
|
||||||
build_admin_monitoring_trace_request_payload_response, parse_admin_monitoring_attempted_only,
|
build_admin_monitoring_trace_request_payload_response_with_key_accounts,
|
||||||
|
parse_admin_monitoring_attempted_only, AdminMonitoringKeyAccountDisplay,
|
||||||
|
};
|
||||||
|
use aether_data_contracts::repository::{
|
||||||
|
candidates::{DecisionTrace, RequestCandidateStatus},
|
||||||
|
provider_catalog::StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::candidates::{DecisionTrace, RequestCandidateStatus};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
pub(super) async fn build_admin_monitoring_trace_request_response(
|
pub(super) async fn build_admin_monitoring_trace_request_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
request_context: &AdminRequestContext<'_>,
|
request_context: &AdminRequestContext<'_>,
|
||||||
) -> Result<Response<Body>, GatewayError> {
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let admin_state = state;
|
||||||
let state = state.as_ref();
|
let state = state.as_ref();
|
||||||
let Some(request_id) =
|
let Some(request_id) =
|
||||||
admin_monitoring_trace_request_id_from_path(&request_context.request_path)
|
admin_monitoring_trace_request_id_from_path(&request_context.request_path)
|
||||||
@@ -56,11 +63,105 @@ pub(super) async fn build_admin_monitoring_trace_request_response(
|
|||||||
.read_request_usage_audit(&request_id)
|
.read_request_usage_audit(&request_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
|
let key_accounts = build_admin_monitoring_key_account_display_map(admin_state, &trace).await?;
|
||||||
|
|
||||||
Ok(build_admin_monitoring_trace_request_payload_response(
|
Ok(
|
||||||
&trace,
|
build_admin_monitoring_trace_request_payload_response_with_key_accounts(
|
||||||
usage.as_ref(),
|
&trace,
|
||||||
))
|
usage.as_ref(),
|
||||||
|
&key_accounts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_monitoring_key_account_display_map(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
trace: &DecisionTrace,
|
||||||
|
) -> Result<BTreeMap<String, AdminMonitoringKeyAccountDisplay>, GatewayError> {
|
||||||
|
let key_ids = trace
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.candidate.key_id.as_deref())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if key_ids.is_empty() {
|
||||||
|
return Ok(BTreeMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = state.read_provider_catalog_keys_by_ids(&key_ids).await?;
|
||||||
|
Ok(keys
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
let display = resolve_admin_monitoring_key_account_display(state, &key)?;
|
||||||
|
Some((key.id, display))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_admin_monitoring_key_account_display(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
key: &StoredProviderCatalogKey,
|
||||||
|
) -> Option<AdminMonitoringKeyAccountDisplay> {
|
||||||
|
let auth_config = parse_admin_monitoring_key_auth_config(state, key);
|
||||||
|
let label = auth_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| {
|
||||||
|
first_non_empty_json_string([
|
||||||
|
config.get("email"),
|
||||||
|
config.get("account_name"),
|
||||||
|
config.get("accountName"),
|
||||||
|
config.get("client_email"),
|
||||||
|
config.get("account_id"),
|
||||||
|
config.get("accountId"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
key.upstream_metadata.as_ref().and_then(|metadata| {
|
||||||
|
first_non_empty_json_string([
|
||||||
|
metadata.get("email"),
|
||||||
|
metadata.get("account_name"),
|
||||||
|
metadata.get("accountName"),
|
||||||
|
metadata.get("account_id"),
|
||||||
|
metadata.get("accountId"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let oauth_plan_type = auth_config.as_ref().and_then(|config| {
|
||||||
|
first_non_empty_json_string([config.get("plan_type"), config.get("planType")])
|
||||||
|
});
|
||||||
|
|
||||||
|
if label.is_none() && oauth_plan_type.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(AdminMonitoringKeyAccountDisplay {
|
||||||
|
label,
|
||||||
|
oauth_plan_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_admin_monitoring_key_auth_config(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
key: &StoredProviderCatalogKey,
|
||||||
|
) -> Option<Map<String, Value>> {
|
||||||
|
let ciphertext = key.encrypted_auth_config.as_deref()?;
|
||||||
|
let plaintext = state.decrypt_catalog_secret_with_fallbacks(ciphertext)?;
|
||||||
|
serde_json::from_str::<Value>(&plaintext)
|
||||||
|
.ok()?
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_non_empty_json_string<'a>(
|
||||||
|
values: impl IntoIterator<Item = Option<&'a Value>>,
|
||||||
|
) -> Option<String> {
|
||||||
|
values.into_iter().find_map(|value| {
|
||||||
|
let text = value?.as_str()?.trim();
|
||||||
|
(!text.is_empty()).then(|| text.to_string())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn build_admin_monitoring_trace_provider_stats_response(
|
pub(super) async fn build_admin_monitoring_trace_provider_stats_response(
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use axum::{
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct AdminMonitoringKeyAccountDisplay {
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub oauth_plan_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum AdminMonitoringRoute {
|
pub enum AdminMonitoringRoute {
|
||||||
AuditLogs,
|
AuditLogs,
|
||||||
@@ -263,6 +269,18 @@ pub fn build_admin_monitoring_trace_provider_stats_payload_response(
|
|||||||
pub fn build_admin_monitoring_trace_request_payload_response(
|
pub fn build_admin_monitoring_trace_request_payload_response(
|
||||||
trace: &DecisionTrace,
|
trace: &DecisionTrace,
|
||||||
usage: Option<&StoredRequestUsageAudit>,
|
usage: Option<&StoredRequestUsageAudit>,
|
||||||
|
) -> Response<Body> {
|
||||||
|
build_admin_monitoring_trace_request_payload_response_with_key_accounts(
|
||||||
|
trace,
|
||||||
|
usage,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_admin_monitoring_trace_request_payload_response_with_key_accounts(
|
||||||
|
trace: &DecisionTrace,
|
||||||
|
usage: Option<&StoredRequestUsageAudit>,
|
||||||
|
key_accounts: &BTreeMap<String, AdminMonitoringKeyAccountDisplay>,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let usage_candidate_id =
|
let usage_candidate_id =
|
||||||
usage.and_then(|item| resolve_admin_monitoring_usage_candidate_id(trace, item));
|
usage.and_then(|item| resolve_admin_monitoring_usage_candidate_id(trace, item));
|
||||||
@@ -274,7 +292,11 @@ pub fn build_admin_monitoring_trace_request_payload_response(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|candidate_id| *candidate_id == item.candidate.id.as_str())
|
.filter(|candidate_id| *candidate_id == item.candidate.id.as_str())
|
||||||
.and(usage);
|
.and(usage);
|
||||||
build_admin_monitoring_trace_request_candidate_payload(item, matched_usage)
|
build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
|
||||||
|
item,
|
||||||
|
matched_usage,
|
||||||
|
key_accounts,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -290,8 +312,24 @@ pub fn build_admin_monitoring_trace_request_payload_response(
|
|||||||
pub fn build_admin_monitoring_trace_request_candidate_payload(
|
pub fn build_admin_monitoring_trace_request_candidate_payload(
|
||||||
item: &DecisionTraceCandidate,
|
item: &DecisionTraceCandidate,
|
||||||
usage: Option<&StoredRequestUsageAudit>,
|
usage: Option<&StoredRequestUsageAudit>,
|
||||||
|
) -> Value {
|
||||||
|
build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
|
||||||
|
item,
|
||||||
|
usage,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
|
||||||
|
item: &DecisionTraceCandidate,
|
||||||
|
usage: Option<&StoredRequestUsageAudit>,
|
||||||
|
key_accounts: &BTreeMap<String, AdminMonitoringKeyAccountDisplay>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let candidate = &item.candidate;
|
let candidate = &item.candidate;
|
||||||
|
let key_account = candidate
|
||||||
|
.key_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|key_id| key_accounts.get(key_id));
|
||||||
json!({
|
json!({
|
||||||
"id": candidate.id,
|
"id": candidate.id,
|
||||||
"request_id": candidate.request_id,
|
"request_id": candidate.request_id,
|
||||||
@@ -310,13 +348,13 @@ pub fn build_admin_monitoring_trace_request_candidate_payload(
|
|||||||
"endpoint_format_acceptance_config": item.endpoint_format_acceptance_config,
|
"endpoint_format_acceptance_config": item.endpoint_format_acceptance_config,
|
||||||
"key_id": candidate.key_id,
|
"key_id": candidate.key_id,
|
||||||
"key_name": item.provider_key_name,
|
"key_name": item.provider_key_name,
|
||||||
"key_account_label": serde_json::Value::Null,
|
"key_account_label": key_account.and_then(|item| item.label.clone()),
|
||||||
"key_preview": serde_json::Value::Null,
|
"key_preview": serde_json::Value::Null,
|
||||||
"key_auth_type": item.provider_key_auth_type,
|
"key_auth_type": item.provider_key_auth_type,
|
||||||
"key_api_formats": item.provider_key_api_formats,
|
"key_api_formats": item.provider_key_api_formats,
|
||||||
"key_internal_priority": item.provider_key_internal_priority,
|
"key_internal_priority": item.provider_key_internal_priority,
|
||||||
"key_global_priority_by_format": item.provider_key_global_priority_by_format,
|
"key_global_priority_by_format": item.provider_key_global_priority_by_format,
|
||||||
"key_oauth_plan_type": serde_json::Value::Null,
|
"key_oauth_plan_type": key_account.and_then(|item| item.oauth_plan_type.clone()),
|
||||||
"key_capabilities": item.provider_key_capabilities,
|
"key_capabilities": item.provider_key_capabilities,
|
||||||
"required_capabilities": candidate.required_capabilities,
|
"required_capabilities": candidate.required_capabilities,
|
||||||
"status": candidate.status,
|
"status": candidate.status,
|
||||||
|
|||||||
@@ -236,6 +236,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_default_body_rules() -> Value {
|
||||||
|
json!([
|
||||||
|
{"action":"drop","path":"max_output_tokens"},
|
||||||
|
{"action":"drop","path":"temperature"},
|
||||||
|
{"action":"drop","path":"top_p"},
|
||||||
|
{"action":"set","path":"store","value":false},
|
||||||
|
{
|
||||||
|
"action":"set",
|
||||||
|
"path":"instructions",
|
||||||
|
"value":"You are GPT-5.",
|
||||||
|
"condition":{"path":"instructions","op":"not_exists"}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_request_body_for_all_standard_surface_pairs_in_sync_and_stream_modes() {
|
fn builds_request_body_for_all_standard_surface_pairs_in_sync_and_stream_modes() {
|
||||||
for client_api_format in STANDARD_SURFACES {
|
for client_api_format in STANDARD_SURFACES {
|
||||||
@@ -269,6 +284,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_codex_body_rules_for_all_standard_sources_to_openai_cli() {
|
||||||
|
let body_rules = codex_default_body_rules();
|
||||||
|
|
||||||
|
for client_api_format in STANDARD_SURFACES {
|
||||||
|
let (mut request, request_path) = sample_request_for(client_api_format);
|
||||||
|
if let Some(object) = request.as_object_mut() {
|
||||||
|
object.insert("temperature".to_string(), json!(0.7));
|
||||||
|
object.insert("top_p".to_string(), json!(0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
let converted = build_standard_request_body(
|
||||||
|
&request,
|
||||||
|
client_api_format,
|
||||||
|
"gpt-5.5",
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
request_path,
|
||||||
|
true,
|
||||||
|
Some(&body_rules),
|
||||||
|
Some("key-1"),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("{client_api_format} -> openai:cli should build with codex body rules")
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "gpt-5.5");
|
||||||
|
assert_eq!(converted["stream"], true);
|
||||||
|
assert_eq!(converted["store"], false);
|
||||||
|
assert!(converted.get("max_output_tokens").is_none());
|
||||||
|
assert!(converted.get("temperature").is_none());
|
||||||
|
assert!(converted.get("top_p").is_none());
|
||||||
|
assert!(
|
||||||
|
converted.get("instructions").is_some(),
|
||||||
|
"{client_api_format} -> openai:cli should keep or inject instructions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_openai_chat_request_from_claude_chat_source() {
|
fn builds_openai_chat_request_from_claude_chat_source() {
|
||||||
let request = json!({
|
let request = json!({
|
||||||
|
|||||||
@@ -32,50 +32,7 @@ pub fn header_rules_are_locally_supported(rules: Option<&Value>) -> bool {
|
|||||||
let Some(rules) = rules else {
|
let Some(rules) = rules else {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let Some(rules) = rules.as_array() else {
|
rules.is_array()
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
rules.iter().all(|rule| {
|
|
||||||
let Some(rule) = rule.as_object() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if rule
|
|
||||||
.get("condition")
|
|
||||||
.is_some_and(|value| !value.is_null() && !condition_is_locally_supported(value))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match rule
|
|
||||||
.get("action")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.map(str::to_ascii_lowercase)
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("set") => {
|
|
||||||
rule.get("key")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
|
||||||
&& rule.get("value").is_some_and(Value::is_string)
|
|
||||||
}
|
|
||||||
Some("drop") => rule
|
|
||||||
.get("key")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| !value.trim().is_empty()),
|
|
||||||
Some("rename") => {
|
|
||||||
rule.get("from")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
|
||||||
&& rule
|
|
||||||
.get("to")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_local_header_rules(
|
pub fn apply_local_header_rules(
|
||||||
@@ -98,11 +55,11 @@ pub fn apply_local_header_rules(
|
|||||||
|
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let Some(rule) = rule.as_object() else {
|
let Some(rule) = rule.as_object() else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(condition) = rule.get("condition").filter(|value| !value.is_null()) {
|
if let Some(condition) = rule.get("condition").filter(|value| !value.is_null()) {
|
||||||
if !condition_is_locally_supported(condition) {
|
if !condition_is_locally_supported(condition) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
if !evaluate_local_condition(body, condition, original_body) {
|
if !evaluate_local_condition(body, condition, original_body) {
|
||||||
continue;
|
continue;
|
||||||
@@ -118,135 +75,66 @@ pub fn apply_local_header_rules(
|
|||||||
{
|
{
|
||||||
Some("set") => {
|
Some("set") => {
|
||||||
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
|
||||||
let Some(value) = rule.get("value").and_then(Value::as_str) else {
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
let key = key.to_ascii_lowercase();
|
let key = key.to_ascii_lowercase();
|
||||||
if !protected_keys.contains(&key) {
|
if key.is_empty() || protected_keys.contains(&key) {
|
||||||
headers.insert(key, value.to_string());
|
continue;
|
||||||
}
|
}
|
||||||
|
let value = rule
|
||||||
|
.get("value")
|
||||||
|
.map(header_rule_value_to_string)
|
||||||
|
.unwrap_or_default();
|
||||||
|
headers.insert(key, value);
|
||||||
}
|
}
|
||||||
Some("drop") => {
|
Some("drop") => {
|
||||||
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let key = key.to_ascii_lowercase();
|
let key = key.to_ascii_lowercase();
|
||||||
if !protected_keys.contains(&key) {
|
if !key.is_empty() && !protected_keys.contains(&key) {
|
||||||
headers.remove(&key);
|
headers.remove(&key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("rename") => {
|
Some("rename") => {
|
||||||
let Some(from) = rule.get("from").and_then(Value::as_str).map(str::trim) else {
|
let Some(from) = rule.get("from").and_then(Value::as_str).map(str::trim) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(to) = rule.get("to").and_then(Value::as_str).map(str::trim) else {
|
let Some(to) = rule.get("to").and_then(Value::as_str).map(str::trim) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let from = from.to_ascii_lowercase();
|
let from = from.to_ascii_lowercase();
|
||||||
let to = to.to_ascii_lowercase();
|
let to = to.to_ascii_lowercase();
|
||||||
if protected_keys.contains(&from) || protected_keys.contains(&to) {
|
if from.is_empty()
|
||||||
|
|| to.is_empty()
|
||||||
|
|| protected_keys.contains(&from)
|
||||||
|
|| protected_keys.contains(&to)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(value) = headers.remove(&from) {
|
if let Some(value) = headers.remove(&from) {
|
||||||
headers.insert(to, value);
|
headers.insert(to, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => return false,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn header_rule_value_to_string(value: &Value) -> String {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn body_rules_are_locally_supported(rules: Option<&Value>) -> bool {
|
pub fn body_rules_are_locally_supported(rules: Option<&Value>) -> bool {
|
||||||
let Some(rules) = rules else {
|
let Some(rules) = rules else {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let Some(rules) = rules.as_array() else {
|
rules.is_array()
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
rules.iter().all(|rule| {
|
|
||||||
let Some(rule) = rule.as_object() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if rule
|
|
||||||
.get("condition")
|
|
||||||
.is_some_and(|value| !value.is_null() && !condition_is_locally_supported(value))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match rule
|
|
||||||
.get("action")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.map(str::to_ascii_lowercase)
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("set") | Some("drop") | Some("append") => rule
|
|
||||||
.get("path")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
.is_some(),
|
|
||||||
Some("rename") => {
|
|
||||||
rule.get("from")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
.is_some()
|
|
||||||
&& rule
|
|
||||||
.get("to")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
Some("insert") => {
|
|
||||||
rule.get("path")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
.is_some()
|
|
||||||
&& rule.get("index").and_then(parse_insert_index).is_some()
|
|
||||||
}
|
|
||||||
Some("regex_replace") => {
|
|
||||||
let Some(path) = rule
|
|
||||||
.get("path")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(_replacement) = rule.get("replacement").and_then(Value::as_str) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(flags) = rule.get("flags").map_or(Some(""), |value| value.as_str()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(_count) = rule
|
|
||||||
.get("count")
|
|
||||||
.map_or(Some(0usize), parse_non_negative_count)
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
!path.is_empty() && !pattern.is_empty() && compile_regex(pattern, flags).is_some()
|
|
||||||
}
|
|
||||||
Some("name_style") => {
|
|
||||||
rule.get("path")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(parse_body_path)
|
|
||||||
.is_some()
|
|
||||||
&& rule
|
|
||||||
.get("style")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(valid_name_style)
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn body_rules_handle_path(rules: Option<&Value>, path: &str) -> bool {
|
pub fn body_rules_handle_path(rules: Option<&Value>, path: &str) -> bool {
|
||||||
@@ -308,14 +196,14 @@ pub fn apply_local_body_rules(
|
|||||||
|
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let Some(rule) = rule.as_object() else {
|
let Some(rule) = rule.as_object() else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let condition = rule.get("condition").filter(|value| !value.is_null());
|
let condition = rule.get("condition").filter(|value| !value.is_null());
|
||||||
let item_condition = condition.is_some_and(condition_has_item_ref);
|
let item_condition = condition.is_some_and(condition_has_item_ref);
|
||||||
if let Some(condition) = condition {
|
if let Some(condition) = condition {
|
||||||
if !condition_is_locally_supported(condition) {
|
if !condition_is_locally_supported(condition) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
if !item_condition && !evaluate_local_condition(body, condition, original_body) {
|
if !item_condition && !evaluate_local_condition(body, condition, original_body) {
|
||||||
continue;
|
continue;
|
||||||
@@ -335,7 +223,7 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let targets = iter_wildcard_targets(
|
let targets = iter_wildcard_targets(
|
||||||
body,
|
body,
|
||||||
@@ -363,7 +251,7 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
for target_path in iter_wildcard_targets(
|
for target_path in iter_wildcard_targets(
|
||||||
body,
|
body,
|
||||||
@@ -383,14 +271,14 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(to) = rule
|
let Some(to) = rule
|
||||||
.get("to")
|
.get("to")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
if has_wildcard(&from) || has_wildcard(&to) {
|
if has_wildcard(&from) || has_wildcard(&to) {
|
||||||
continue;
|
continue;
|
||||||
@@ -403,7 +291,7 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let value = rule.get("value").cloned().unwrap_or(Value::Null);
|
let value = rule.get("value").cloned().unwrap_or(Value::Null);
|
||||||
for target_path in iter_wildcard_targets(
|
for target_path in iter_wildcard_targets(
|
||||||
@@ -428,10 +316,10 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(index) = rule.get("index").and_then(parse_insert_index) else {
|
let Some(index) = rule.get("index").and_then(parse_insert_index) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
if has_wildcard(&path) {
|
if has_wildcard(&path) {
|
||||||
continue;
|
continue;
|
||||||
@@ -450,28 +338,24 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
|
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(replacement) = rule.get("replacement").and_then(Value::as_str) else {
|
let Some(replacement) = rule.get("replacement").and_then(Value::as_str) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(flags) = rule.get("flags").map_or(Some(""), |value| value.as_str()) else {
|
let flags = rule.get("flags").and_then(Value::as_str).unwrap_or("");
|
||||||
return false;
|
let count = rule
|
||||||
};
|
|
||||||
let Some(count) = rule
|
|
||||||
.get("count")
|
.get("count")
|
||||||
.map_or(Some(0usize), parse_non_negative_count)
|
.and_then(parse_non_negative_count)
|
||||||
else {
|
.unwrap_or(0);
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if pattern.is_empty() {
|
if pattern.is_empty() {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(pattern) = compile_regex(pattern, flags) else {
|
let Some(pattern) = compile_regex(pattern, flags) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
for target_path in iter_wildcard_targets(
|
for target_path in iter_wildcard_targets(
|
||||||
body,
|
body,
|
||||||
@@ -501,13 +385,13 @@ pub fn apply_local_body_rules(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(parse_body_path)
|
.and_then(parse_body_path)
|
||||||
else {
|
else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(style) = rule.get("style").and_then(Value::as_str) else {
|
let Some(style) = rule.get("style").and_then(Value::as_str) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
if !valid_name_style(style) {
|
if !valid_name_style(style) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
for target_path in iter_wildcard_targets(
|
for target_path in iter_wildcard_targets(
|
||||||
body,
|
body,
|
||||||
@@ -526,7 +410,7 @@ pub fn apply_local_body_rules(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => return false,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,7 +1040,7 @@ fn compile_regex(pattern: &str, flags: &str) -> Option<Regex> {
|
|||||||
's' => {
|
's' => {
|
||||||
builder.dot_matches_new_line(true);
|
builder.dot_matches_new_line(true);
|
||||||
}
|
}
|
||||||
_ => return None,
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.build().ok()
|
builder.build().ok()
|
||||||
@@ -1629,16 +1513,87 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn body_rules_reject_invalid_regex_flags_and_negative_count() {
|
fn body_rules_tolerate_invalid_regex_flags_and_negative_count() {
|
||||||
let invalid_flags = serde_json::json!([
|
let invalid_flags = serde_json::json!([
|
||||||
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","flags":"ix"}
|
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","flags":"ix"}
|
||||||
]);
|
]);
|
||||||
let invalid_count = serde_json::json!([
|
let invalid_count = serde_json::json!([
|
||||||
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","count":-1}
|
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","count":-1}
|
||||||
]);
|
]);
|
||||||
|
let mut flags_body = serde_json::json!({"text":"foo"});
|
||||||
|
let mut count_body = serde_json::json!({"text":"foo foo"});
|
||||||
|
|
||||||
assert!(!body_rules_are_locally_supported(Some(&invalid_flags)));
|
assert!(body_rules_are_locally_supported(Some(&invalid_flags)));
|
||||||
assert!(!body_rules_are_locally_supported(Some(&invalid_count)));
|
assert!(body_rules_are_locally_supported(Some(&invalid_count)));
|
||||||
|
assert!(apply_local_body_rules(
|
||||||
|
&mut flags_body,
|
||||||
|
Some(&invalid_flags),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
assert!(apply_local_body_rules(
|
||||||
|
&mut count_body,
|
||||||
|
Some(&invalid_count),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
assert_eq!(flags_body["text"], "bar");
|
||||||
|
assert_eq!(count_body["text"], "bar bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_rules_skip_invalid_entries_without_rejecting_whole_body() {
|
||||||
|
let rules = serde_json::json!([
|
||||||
|
{"action":"set","path":".bad","value":1},
|
||||||
|
{"action":"drop","path":"missing."},
|
||||||
|
{"action":"regex_replace","path":"text","pattern":"(","replacement":"x"},
|
||||||
|
{"op":"remove","path":"/legacy"},
|
||||||
|
{"action":"set","path":"ok","value":true}
|
||||||
|
]);
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"text": "keep",
|
||||||
|
"legacy": true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(apply_local_body_rules(&mut body, Some(&rules), None));
|
||||||
|
|
||||||
|
assert_eq!(body["text"], "keep");
|
||||||
|
assert_eq!(body["legacy"], true);
|
||||||
|
assert_eq!(body["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_rules_skip_invalid_entries_without_rejecting_whole_headers() {
|
||||||
|
let rules = serde_json::json!([
|
||||||
|
{"action":"set","key":"","value":"bad"},
|
||||||
|
{"action":"set","key":"x-json","value":{"nested":true}},
|
||||||
|
{"action":"drop","key":null},
|
||||||
|
{"action":"rename","from":"x-missing","to":""},
|
||||||
|
{"op":"remove","key":"x-legacy"},
|
||||||
|
{"action":"set","key":"x-ok","value":"yes"}
|
||||||
|
]);
|
||||||
|
let mut headers = std::collections::BTreeMap::from([(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer keep".to_string(),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
assert!(header_rules_are_locally_supported(Some(&rules)));
|
||||||
|
assert!(apply_local_header_rules(
|
||||||
|
&mut headers,
|
||||||
|
Some(&rules),
|
||||||
|
&["authorization"],
|
||||||
|
&serde_json::json!({}),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("authorization").map(String::as_str),
|
||||||
|
Some("Bearer keep")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-json").map(String::as_str),
|
||||||
|
Some("{\"nested\":true}")
|
||||||
|
);
|
||||||
|
assert_eq!(headers.get("x-ok").map(String::as_str), Some("yes"));
|
||||||
|
assert!(!headers.contains_key("x-legacy"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user