feat(body_rules): 支持通配符路径、范围索引、name_style action 和 $item 条件引用

- 路径语法新增 [*] 通配符(遍历所有元素)和 [N-M] 范围索引
- 新增 name_style action,支持 snake_case/camelCase/PascalCase/kebab-case/capitalize 风格转换
- condition 支持 $item.xxx 引用通配符当前元素,实现逐元素条件过滤
- 前端同步更新类型定义、表单 UI 和帮助文档
- 提取 isBodyRuleEffective 函数消除重复的规则有效性判断逻辑
This commit is contained in:
fawney19
2026-02-21 14:59:37 +08:00
parent 05177dffb3
commit bb7d393128
5 changed files with 883 additions and 53 deletions

View File

@@ -135,7 +135,19 @@ export interface BodyRuleCondition {
value?: any // exists / not_exists 不需要 value value?: any // exists / not_exists 不需要 value
} }
export type BodyRule = (BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace) & { /**
* 请求体规则 - 转换命名风格
*
* - path 指向目标字符串字段,支持通配符如 "tools[*].name"
* - style 为目标命名风格
*/
export interface BodyRuleNameStyle {
action: 'name_style'
path: string
style: 'snake_case' | 'camelCase' | 'PascalCase' | 'kebab-case' | 'capitalize'
}
export type BodyRule = (BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace | BodyRuleNameStyle) & {
condition?: BodyRuleCondition condition?: BodyRuleCondition
} }

View File

@@ -296,7 +296,7 @@
v-if="getEndpointEditBodyRules(endpoint.id).length > 0" v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
class="flex items-center gap-1 text-xs text-muted-foreground px-2" class="flex items-center gap-1 text-xs text-muted-foreground px-2"
> >
<span><code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引;值为 JSON 格式</span> <span><code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引 / <code class="bg-muted px-1 rounded">[*]</code> 通配符;值为 JSON 格式</span>
<div class="flex-1" /> <div class="flex-1" />
<Popover <Popover
:open="bodyRuleHelpOpenEndpointId === endpoint.id" :open="bodyRuleHelpOpenEndpointId === endpoint.id"
@@ -326,6 +326,8 @@
<div class="text-muted-foreground"> <div class="text-muted-foreground">
<code>metadata.user_id</code> 嵌套字段<br> <code>metadata.user_id</code> 嵌套字段<br>
<code>messages[0].content</code> 数组索引<br> <code>messages[0].content</code> 数组索引<br>
<code>tools[*].name</code> 通配符(遍历所有元素)<br>
<code>tools[0-4].name</code> 范围(遍历索引 0~4<br>
<code>config\.v1.key</code> 转义点号 <code>config\.v1.key</code> 转义点号
</div> </div>
</div> </div>
@@ -339,6 +341,14 @@
<code v-pre>{{$original}}</code> 引用原值 <code v-pre>{{$original}}</code> 引用原值
</div> </div>
</div> </div>
<div>
<div class="font-medium mb-0.5">
命名风格
</div>
<div class="text-muted-foreground">
批量转换字段命名capitalize / snake_case / camelCase / PascalCase / kebab-case
</div>
</div>
<div> <div>
<div class="font-medium mb-0.5"> <div class="font-medium mb-0.5">
条件运算符 条件运算符
@@ -350,12 +360,16 @@
<code>matches</code> 正则匹配<br> <code>matches</code> 正则匹配<br>
<code>exists</code> <code>not_exists</code> 字段存在性<br> <code>exists</code> <code>not_exists</code> 字段存在性<br>
<code>in</code> 在列表中(值填 <code>["a","b"]</code><br> <code>in</code> 在列表中(值填 <code>["a","b"]</code><br>
<code>type_is</code> 类型判断string/number/boolean/array/object/null <code>type_is</code> 类型判断string/number/boolean/array/object/null<br>
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素
</div> </div>
</div> </div>
<div class="text-muted-foreground"> <div class="text-muted-foreground">
规则按顺序执行,前面的修改对后续规则可见。 规则按顺序执行,前面的修改对后续规则可见。
</div> </div>
<div class="text-muted-foreground">
规则在格式转换之后执行,路径需按目标提供商的请求体结构填写。
</div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -398,6 +412,9 @@
<SelectItem value="regex_replace"> <SelectItem value="regex_replace">
正则替换 正则替换
</SelectItem> </SelectItem>
<SelectItem value="name_style">
命名风格
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
@@ -524,6 +541,41 @@
:title="getRegexPatternValidationTip(rule)" :title="getRegexPatternValidationTip(rule)"
/> />
</template> </template>
<template v-else-if="rule.action === 'name_style'">
<Input
:model-value="rule.path"
placeholder="字段路径 tools[*].name"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Select
:model-value="rule.style || 'capitalize'"
@update:model-value="(v: string) => updateEndpointBodyRuleField(endpoint.id, index, 'style', v)"
>
<SelectTrigger class="w-[120px] h-7 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="capitalize">
Capitalize
</SelectItem>
<SelectItem value="snake_case">
snake_case
</SelectItem>
<SelectItem value="camelCase">
camelCase
</SelectItem>
<SelectItem value="PascalCase">
PascalCase
</SelectItem>
<SelectItem value="kebab-case">
kebab-case
</SelectItem>
</SelectContent>
</Select>
</template>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -541,7 +593,7 @@
<span class="text-[10px] font-semibold text-muted-foreground shrink-0">IF</span> <span class="text-[10px] font-semibold text-muted-foreground shrink-0">IF</span>
<Input <Input
:model-value="rule.conditionPath" :model-value="rule.conditionPath"
placeholder="字段路径" :placeholder="rule.path?.includes('[*]') || rule.path?.match(/\[\d+-\d+\]/) ? '$item.字段名' : '字段路径'"
size="sm" size="sm"
class="flex-1 min-w-0 h-7 text-xs" class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionPath', v)" @update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionPath', v)"
@@ -742,6 +794,7 @@ import {
type HeaderRule, type HeaderRule,
type BodyRule, type BodyRule,
type BodyRuleRegexReplace, type BodyRuleRegexReplace,
type BodyRuleNameStyle,
type BodyRuleCondition, type BodyRuleCondition,
type BodyRuleConditionOp, type BodyRuleConditionOp,
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -758,7 +811,7 @@ interface EditableRule {
} }
// 编辑用的请求体规则类型 // 编辑用的请求体规则类型
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace' type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace' | 'name_style'
interface EditableBodyRule { interface EditableBodyRule {
action: BodyRuleAction action: BodyRuleAction
@@ -770,6 +823,7 @@ interface EditableBodyRule {
pattern: string // regex_replace 用 pattern: string // regex_replace 用
replacement: string // regex_replace 用 replacement: string // regex_replace 用
flags: string // regex_replace 用i/m/s flags: string // regex_replace 用i/m/s
style: string // name_style 用snake_case/camelCase/PascalCase/kebab-case/capitalize
conditionEnabled: boolean // 是否启用条件 conditionEnabled: boolean // 是否启用条件
conditionPath: string conditionPath: string
conditionOp: string conditionOp: string
@@ -1082,7 +1136,7 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
} }
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({ const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '',
conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '',
}) })
@@ -1115,6 +1169,8 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields }) bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields })
} else if (rule.action === 'regex_replace') { } else if (rule.action === 'regex_replace') {
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', ...conditionFields }) bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', ...conditionFields })
} else if (rule.action === 'name_style') {
bodyRules.push({ ...emptyBodyRule(), action: 'name_style', path: rule.path || '', style: rule.style || 'capitalize', ...conditionFields })
} }
} }
} }
@@ -1303,7 +1359,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
// 添加请求体规则(同时自动展开折叠) // 添加请求体规则(同时自动展开折叠)
function handleAddEndpointBodyRule(endpointId: string) { function handleAddEndpointBodyRule(endpointId: string) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '' }) rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '' })
// 自动展开折叠 // 自动展开折叠
endpointRulesExpanded.value[endpointId] = true endpointRulesExpanded.value[endpointId] = true
} }
@@ -1327,11 +1383,12 @@ function updateEndpointBodyRuleAction(endpointId: string, index: number, action:
rules[index].pattern = '' rules[index].pattern = ''
rules[index].replacement = '' rules[index].replacement = ''
rules[index].flags = '' rules[index].flags = ''
rules[index].style = ''
} }
} }
// 更新请求体规则字段 // 更新请求体规则字段
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'conditionPath' | 'conditionOp' | 'conditionValue', value: string) { function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'style' | 'conditionPath' | 'conditionOp' | 'conditionValue', value: string) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) { if (rules[index]) {
rules[index][field] = value rules[index][field] = value
@@ -1520,17 +1577,31 @@ function getBodySetValueValidationTip(rule: EditableBodyRule): string {
} }
} }
// 判断请求体规则是否有效(必填字段已填写)
function isBodyRuleEffective(r: EditableBodyRule): boolean {
switch (r.action) {
case 'set':
case 'drop':
return !!r.path.trim()
case 'rename':
return !!(r.from.trim() && r.to.trim())
case 'insert':
case 'append':
return !!r.path.trim()
case 'regex_replace':
return !!(r.path.trim() && r.pattern.trim())
case 'name_style':
return !!(r.path.trim() && r.style.trim())
default:
return false
}
}
// 获取端点的请求体规则数量(有效的规则) // 获取端点的请求体规则数量(有效的规则)
function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number { function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id] const state = endpointEditStates.value[endpoint.id]
if (state) { if (state) {
return state.bodyRules.filter(r => { return state.bodyRules.filter(isBodyRuleEffective).length
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
return false
}).length
} }
return endpoint.body_rules?.length || 0 return endpoint.body_rules?.length || 0
} }
@@ -1592,13 +1663,7 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
if (!state) return false if (!state) return false
const originalRules = endpoint.body_rules || [] const originalRules = endpoint.body_rules || []
const editedRules = state.bodyRules.filter(r => { const editedRules = state.bodyRules.filter(isBodyRuleEffective)
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
return false
})
if (editedRules.length !== originalRules.length) return true if (editedRules.length !== originalRules.length) return true
for (let i = 0; i < editedRules.length; i++) { for (let i = 0; i < editedRules.length; i++) {
const edited = editedRules[i] const edited = editedRules[i]
@@ -1629,6 +1694,9 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
if (edited.pattern !== (original.pattern ?? '')) return true if (edited.pattern !== (original.pattern ?? '')) return true
if (edited.replacement !== (original.replacement ?? '')) return true if (edited.replacement !== (original.replacement ?? '')) return true
if (edited.flags !== (original.flags ?? '')) return true if (edited.flags !== (original.flags ?? '')) return true
} else if (edited.action === 'name_style' && original.action === 'name_style') {
if (edited.path !== (original.path ?? '')) return true
if (edited.style !== (original.style ?? '')) return true
} }
// 条件变更检测 // 条件变更检测
const origCond = original.condition const origCond = original.condition
@@ -1693,6 +1761,8 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}), ...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
} }
result.push({ ...entry, ...(condition ? { condition } : {}) }) result.push({ ...entry, ...(condition ? { condition } : {}) })
} else if (rule.action === 'name_style' && rule.path.trim() && rule.style.trim()) {
result.push({ action: 'name_style', path: rule.path.trim(), style: rule.style.trim() as BodyRuleNameStyle['style'], ...(condition ? { condition } : {}) })
} }
} }
@@ -1740,6 +1810,10 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
if (!validFlags.has(f)) return `${prefix}flags 仅允许 i/m/s非法字符: ${f}` if (!validFlags.has(f)) return `${prefix}flags 仅允许 i/m/s非法字符: ${f}`
} }
} }
} else if (rule.action === 'name_style') {
if (!rule.path.trim()) return `${prefix}路径不能为空`
const validStyles = new Set(['snake_case', 'camelCase', 'PascalCase', 'kebab-case', 'capitalize'])
if (!rule.style.trim() || !validStyles.has(rule.style.trim())) return `${prefix}请选择有效的命名风格`
} }
} }
return null return null

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import copy import copy
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any from typing import Any
from src.core.api_format import ( from src.core.api_format import (
@@ -120,13 +121,31 @@ def build_test_request_body(
# ============================================================================== # ==============================================================================
# 路径段类型str 表示 dict keyint 表示数组索引 @dataclass(frozen=True, slots=True)
PathSegment = str | int class _WildcardSlice:
"""表示数组通配符路径段: [*] 或 [start-end]"""
start: int | None # None 表示 [*]
end: int | None
def resolve(self, length: int) -> range:
"""根据实际数组长度返回索引 range"""
if self.start is None:
return range(length)
s = max(0, self.start)
e = min(length - 1, self.end if self.end is not None else length - 1)
return range(s, e + 1) if s <= e else range(0)
# 路径段类型str 表示 dict keyint 表示数组索引_WildcardSlice 表示通配
PathSegment = str | int | _WildcardSlice
_RANGE_RE = re.compile(r"^(\d+)\s*-\s*(\d+)$")
def _parse_path(path: str) -> list[PathSegment]: def _parse_path(path: str) -> list[PathSegment]:
""" """
解析路径,支持点号分隔、转义数组索引。 解析路径,支持点号分隔、转义数组索引、通配符和范围
Examples: Examples:
"metadata.user.name" -> ["metadata", "user", "name"] "metadata.user.name" -> ["metadata", "user", "name"]
@@ -135,11 +154,15 @@ def _parse_path(path: str) -> list[PathSegment]:
"data[0].items[2].name" -> ["data", 0, "items", 2, "name"] "data[0].items[2].name" -> ["data", 0, "items", 2, "name"]
"messages[-1]" -> ["messages", -1] "messages[-1]" -> ["messages", -1]
"matrix[0][1]" -> ["matrix", 0, 1] "matrix[0][1]" -> ["matrix", 0, 1]
"tools[*].name" -> ["tools", _WildcardSlice(None, None), "name"]
"tools[0-4].name" -> ["tools", _WildcardSlice(0, 4), "name"]
约束: 约束:
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。 - 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。 - 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
- 数组索引必须是整数(支持负数索引)。 - 数组索引必须是整数(支持负数索引)。
- [*] 表示遍历数组所有元素。
- [N-M] 表示遍历数组索引 N 到 M含两端
""" """
raw = (path or "").strip() raw = (path or "").strip()
if not raw: if not raw:
@@ -172,7 +195,7 @@ def _parse_path(path: str) -> list[PathSegment]:
i += 1 i += 1
continue continue
# 数组索引:[N] # 数组索引:[N] / [*] / [N-M]
if ch == "[": if ch == "[":
# 先将当前累积的 key 入栈 # 先将当前累积的 key 入栈
if current: if current:
@@ -190,12 +213,22 @@ def _parse_path(path: str) -> list[PathSegment]:
if not index_str: if not index_str:
return [] # 空索引 return [] # 空索引
# [*] 通配符
if index_str == "*":
parts.append(_WildcardSlice(None, None))
else:
# [N-M] 范围
m = _RANGE_RE.match(index_str)
if m:
parts.append(_WildcardSlice(int(m.group(1)), int(m.group(2))))
else:
# 普通整数索引
try: try:
idx = int(index_str) idx = int(index_str)
except ValueError: except ValueError:
return [] # 非整数索引 return [] # 非整数索引
parts.append(idx) parts.append(idx)
expect_key = False expect_key = False
i = j + 1 i = j + 1
continue continue
@@ -214,6 +247,86 @@ def _parse_path(path: str) -> list[PathSegment]:
return parts if parts else [] return parts if parts else []
def _has_wildcard(parts: list[PathSegment]) -> bool:
"""检查路径段列表中是否包含通配符"""
return any(isinstance(p, _WildcardSlice) for p in parts)
def _expand_wildcard_paths(
obj: Any, parts: list[PathSegment], *, require_leaf: bool = False
) -> list[list[str | int]]:
"""
将含通配符的路径段展开为具体的路径段列表。
遍历 obj 结构,遇到 _WildcardSlice 时根据实际数组长度展开为具体索引。
返回的每条路径都是纯 str|int 段,不含通配符。
Args:
obj: 要遍历的数据结构
parts: 含通配符的路径段列表
require_leaf: 是否要求叶子节点存在False 时只要父级存在即可,适用于 set
"""
result: list[list[str | int]] = []
def _recurse(current: Any, idx: int, prefix: list[str | int]) -> None:
if idx == len(parts):
result.append(prefix[:])
return
seg = parts[idx]
is_last = idx == len(parts) - 1
if isinstance(seg, _WildcardSlice):
if not isinstance(current, list):
return
for i in seg.resolve(len(current)):
prefix.append(i)
try:
_recurse(current[i], idx + 1, prefix)
except IndexError:
pass
prefix.pop()
elif isinstance(seg, int):
if isinstance(current, list):
try:
prefix.append(seg)
_recurse(current[seg], idx + 1, prefix)
prefix.pop()
except IndexError:
prefix.pop()
else:
# str key
if isinstance(current, dict):
if seg in current:
prefix.append(seg)
_recurse(current[seg], idx + 1, prefix)
prefix.pop()
elif is_last and not require_leaf:
# 叶子节点不存在但允许创建set 场景)
prefix.append(seg)
result.append(prefix[:])
prefix.pop()
_recurse(obj, 0, [])
return result
def _segments_to_path(segments: list[str | int]) -> str:
"""将路径段列表转回路径字符串(用于调用现有的 _set/_get/_delete 函数)"""
parts: list[str] = []
for seg in segments:
if isinstance(seg, int):
parts.append(f"[{seg}]")
else:
# 转义字面量点号
escaped = seg.replace(".", "\\.")
if parts and not parts[-1].endswith("]"):
parts.append(f".{escaped}")
else:
parts.append(escaped)
return "".join(parts)
def _get_nested_value(obj: Any, path: str) -> tuple[bool, Any]: def _get_nested_value(obj: Any, path: str) -> tuple[bool, Any]:
""" """
获取嵌套值,支持 dict 和 list 混合遍历 获取嵌套值,支持 dict 和 list 混合遍历
@@ -408,6 +521,45 @@ def _extract_path(
_ORIGINAL_PLACEHOLDER = "{{$original}}" _ORIGINAL_PLACEHOLDER = "{{$original}}"
# ==============================================================================
# 命名风格转换
# ==============================================================================
_NAME_STYLE_VALUES = frozenset(
{"snake_case", "camelCase", "PascalCase", "kebab-case", "capitalize"}
)
# 拆分标识符为单词列表(支持 camelCase / PascalCase / snake_case / kebab-case / 混合)
_WORD_SPLIT_RE = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\b)|[A-Z]|[0-9]+")
def _split_identifier(name: str) -> list[str]:
"""将标识符拆分为小写单词列表"""
# 先把常见分隔符替换为空格
normalized = name.replace("_", " ").replace("-", " ")
words = _WORD_SPLIT_RE.findall(normalized)
return [w.lower() for w in words if w]
def _convert_name_style(name: str, style: str) -> str:
"""将标识符转换为指定命名风格"""
words = _split_identifier(name)
if not words:
return name
if style == "snake_case":
return "_".join(words)
elif style == "camelCase":
return words[0] + "".join(w.capitalize() for w in words[1:])
elif style == "PascalCase":
return "".join(w.capitalize() for w in words)
elif style == "kebab-case":
return "-".join(words)
elif style == "capitalize":
# 仅首字母大写,保留其余部分不变
return name[0].upper() + name[1:] if name else name
return name
def _contains_original_placeholder(value: Any) -> bool: def _contains_original_placeholder(value: Any) -> bool:
"""递归检查 value 中是否包含 {{$original}} 占位符""" """递归检查 value 中是否包含 {{$original}} 占位符"""
@@ -442,6 +594,113 @@ def _resolve_original_placeholder(template: Any, original: Any) -> Any:
return template return template
_ITEM_PREFIX = "$item."
_ITEM_EXACT = "$item"
def _has_item_ref(condition: dict[str, Any] | None) -> bool:
"""检查 condition 的 path 是否包含 $item 引用"""
if not condition or not isinstance(condition, dict):
return False
path = condition.get("path", "")
return isinstance(path, str) and (
path.strip().startswith(_ITEM_PREFIX) or path.strip() == _ITEM_EXACT
)
def _resolve_item_condition(
condition: dict[str, Any],
item_path_prefix: str,
) -> dict[str, Any]:
"""将 condition 中的 $item 引用替换为具体的元素路径前缀。
例如:
condition = {"path": "$item.name", "op": "in", "value": ["writer"]}
item_path_prefix = "tools[0]"
-> {"path": "tools[0].name", "op": "in", "value": ["writer"]}
condition = {"path": "$item", "op": "type_is", "value": "object"}
item_path_prefix = "tools[0]"
-> {"path": "tools[0]", "op": "type_is", "value": "object"}
"""
resolved = dict(condition)
raw_path = resolved.get("path", "").strip()
if raw_path == _ITEM_EXACT:
resolved["path"] = item_path_prefix
elif raw_path.startswith(_ITEM_PREFIX):
suffix = raw_path[len(_ITEM_PREFIX) :]
resolved["path"] = f"{item_path_prefix}.{suffix}"
return resolved
def _get_item_prefix_from_concrete(
concrete_segs: list[str | int],
wildcard_parts: list[PathSegment],
) -> str:
"""从展开后的具体路径段中,提取通配符所在层级的元素路径前缀。
例如:
concrete_segs = ["tools", 0, "name"]
wildcard_parts = ["tools", _WildcardSlice, "name"]
-> "tools[0]" (通配符在 index 1取 concrete_segs[:2])
concrete_segs = ["data", 1, "items", 2, "name"]
wildcard_parts = ["data", _WildcardSlice, "items", _WildcardSlice, "name"]
-> "data[1].items[2]" (取到最后一个通配符位置+1)
"""
# 找到最后一个通配符在 wildcard_parts 中的位置
last_wc_idx = 0
for i, seg in enumerate(wildcard_parts):
if isinstance(seg, _WildcardSlice):
last_wc_idx = i
# concrete_segs 中对应位置 +1 就是元素前缀的结束
prefix_segs = concrete_segs[: last_wc_idx + 1]
return _segments_to_path(prefix_segs)
def _iter_wildcard_targets(
result: dict[str, Any],
path: str,
parts: list[PathSegment],
condition: dict[str, Any] | None,
item_condition: bool,
*,
require_leaf: bool = False,
reverse: bool = False,
) -> list[str]:
"""通配符路径展开 + $item 条件过滤的通用逻辑。
如果路径不含通配符,返回 [path] 本身(单元素列表)。
如果含通配符,展开后逐条评估 $item 条件,返回通过条件的具体路径列表。
Args:
result: 当前请求体(用于展开和条件评估)
path: 原始路径字符串(不含通配符时直接返回)
parts: 已解析的路径段列表
condition: 规则的 condition 字典
item_condition: condition 是否包含 $item 引用
require_leaf: 是否要求叶子节点存在
reverse: 是否倒序返回drop 场景需要倒序避免索引偏移)
"""
if not _has_wildcard(parts):
return [path]
expanded = _expand_wildcard_paths(result, parts, require_leaf=require_leaf)
if reverse:
expanded = list(reversed(expanded))
targets: list[str] = []
for concrete_segs in expanded:
if item_condition:
prefix = _get_item_prefix_from_concrete(concrete_segs, parts)
resolved = _resolve_item_condition(condition, prefix) # type: ignore[arg-type]
if not _evaluate_condition(result, resolved):
continue
targets.append(_segments_to_path(concrete_segs))
return targets
# ============================================================================== # ==============================================================================
# 条件评估器 # 条件评估器
# ============================================================================== # ==============================================================================
@@ -568,6 +827,8 @@ def apply_body_rules(
- 支持多层嵌套data[0].items[2].name - 支持多层嵌套data[0].items[2].name
- 支持负数索引messages[-1] - 支持负数索引messages[-1]
- 支持连续数组索引matrix[0][1] - 支持连续数组索引matrix[0][1]
- 通配符 [*]:遍历数组所有元素,如 tools[*].name
- 范围 [N-M]:遍历数组索引 N 到 M含两端如 tools[0-4].name
支持的规则类型: 支持的规则类型:
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123} - set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
@@ -578,6 +839,9 @@ def apply_body_rules(
- insert: 在数组指定位置插入元素 {"action": "insert", "path": "messages", "index": 0, "value": {...}} - insert: 在数组指定位置插入元素 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
- regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "messages[0].content", - regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "messages[0].content",
"pattern": "\\bfoo\\b", "replacement": "bar", "flags": "i", "count": 0} "pattern": "\\bfoo\\b", "replacement": "bar", "flags": "i", "count": 0}
- name_style: 转换字符串命名风格 {"action": "name_style", "path": "tools[*].name",
"style": "camelCase"}
支持的风格: snake_case, camelCase, PascalCase, kebab-case, capitalize
Args: Args:
body: 原始请求体 body: 原始请求体
@@ -599,9 +863,13 @@ def apply_body_rules(
if not isinstance(rule, dict): if not isinstance(rule, dict):
continue continue
# 条件触发:condition 存在且不满足时跳过规则 # 条件触发:
# - $item 引用的 condition 延迟到通配符循环内逐元素评估
# - 普通 condition 在此全局评估,不满足则跳过整条规则
condition = rule.get("condition") condition = rule.get("condition")
if condition is not None and not _evaluate_condition(result, condition): item_condition = _has_item_ref(condition)
if condition is not None and not item_condition:
if not _evaluate_condition(result, condition):
continue continue
action = rule.get("action") action = rule.get("action")
@@ -613,17 +881,31 @@ def apply_body_rules(
path = _extract_path(rule, protected_lower) path = _extract_path(rule, protected_lower)
if not path: if not path:
continue continue
parts = _parse_path(path)
for target_path in _iter_wildcard_targets(
result, path, parts, condition, item_condition
):
value = rule.get("value") value = rule.get("value")
if _contains_original_placeholder(value): if _contains_original_placeholder(value):
found, original = _get_nested_value(result, path) found, original = _get_nested_value(result, target_path)
value = _resolve_original_placeholder(value, original if found else None) value = _resolve_original_placeholder(value, original if found else None)
_set_nested_value(result, path, value) _set_nested_value(result, target_path, value)
elif action == "drop": elif action == "drop":
path = _extract_path(rule, protected_lower) path = _extract_path(rule, protected_lower)
if not path: if not path:
continue continue
_delete_nested_value(result, path) parts = _parse_path(path)
for target_path in _iter_wildcard_targets(
result,
path,
parts,
condition,
item_condition,
require_leaf=True,
reverse=True,
):
_delete_nested_value(result, target_path)
elif action == "rename": elif action == "rename":
raw_from = rule.get("from", "") raw_from = rule.get("from", "")
@@ -645,15 +927,22 @@ def apply_body_rules(
): ):
continue continue
# rename 不支持通配符(语义不明确)
if _has_wildcard(from_parts) or _has_wildcard(to_parts):
continue
_rename_nested_value(result, from_path, to_path) _rename_nested_value(result, from_path, to_path)
elif action == "append": elif action == "append":
path = _extract_path(rule, protected_lower) path = _extract_path(rule, protected_lower)
if not path: if not path:
continue continue
found, target = _get_nested_value(result, path) parts = _parse_path(path)
if not found or not isinstance(target, list): for target_path in _iter_wildcard_targets(
continue result, path, parts, condition, item_condition, require_leaf=True
):
found, target = _get_nested_value(result, target_path)
if found and isinstance(target, list):
target.append(rule.get("value")) target.append(rule.get("value"))
elif action == "insert": elif action == "insert":
@@ -663,6 +952,7 @@ def apply_body_rules(
index = rule.get("index") index = rule.get("index")
if not isinstance(index, int): if not isinstance(index, int):
continue continue
# insert 不支持通配符(索引语义冲突)
found, target = _get_nested_value(result, path) found, target = _get_nested_value(result, path)
if not found or not isinstance(target, list): if not found or not isinstance(target, list):
continue continue
@@ -686,15 +976,34 @@ def apply_body_rules(
if not isinstance(count, int) or count < 0: if not isinstance(count, int) or count < 0:
count = 0 count = 0
found, current_val = _get_nested_value(result, path) try:
if not found or not isinstance(current_val, str): compiled = re.compile(pattern, re_flags)
except re.error:
continue continue
try: parts = _parse_path(path)
new_val = re.compile(pattern, re_flags).sub(replacement, current_val, count=count) for target_path in _iter_wildcard_targets(
_set_nested_value(result, path, new_val) result, path, parts, condition, item_condition, require_leaf=True
except re.error: ):
continue # 正则表达式无效,跳过 found, current_val = _get_nested_value(result, target_path)
if found and isinstance(current_val, str):
new_val = compiled.sub(replacement, current_val, count=count)
_set_nested_value(result, target_path, new_val)
elif action == "name_style":
path = _extract_path(rule, protected_lower)
if not path:
continue
style = rule.get("style")
if not isinstance(style, str) or style not in _NAME_STYLE_VALUES:
continue
parts = _parse_path(path)
for target_path in _iter_wildcard_targets(
result, path, parts, condition, item_condition, require_leaf=True
):
found, current_val = _get_nested_value(result, target_path)
if found and isinstance(current_val, str):
_set_nested_value(result, target_path, _convert_name_style(current_val, style))
return result return result

View File

@@ -30,13 +30,20 @@ HeaderRule = dict[str, Any]
# - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}} # - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
# - insert: 在数组指定位置插入 {"action": "insert", "path": "messages", "index": 0, "value": {...}} # - insert: 在数组指定位置插入 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
# - regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "...", "pattern": "...", "replacement": "..."} # - regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "...", "pattern": "...", "replacement": "..."}
# - name_style: 转换命名风格 {"action": "name_style", "path": "tools[*].name", "style": "camelCase"}
# 路径语法支持数组索引messages[0].content, data[-1], matrix[0][1] # 路径语法支持数组索引messages[0].content, data[-1], matrix[0][1]
# 路径语法支持通配符tools[*].name遍历所有元素, tools[0-4].name遍历范围
# 运行时处理在 request_builder.py 的 apply_body_rules 中;结构校验见 _validate_body_rules # 运行时处理在 request_builder.py 的 apply_body_rules 中;结构校验见 _validate_body_rules
BodyRule = dict[str, Any] BodyRule = dict[str, Any]
# body_rules 允许的 action 集合 # body_rules 允许的 action 集合
_BODY_RULE_ACTIONS: frozenset[str] = frozenset( _BODY_RULE_ACTIONS: frozenset[str] = frozenset(
{"set", "drop", "rename", "append", "insert", "regex_replace"} {"set", "drop", "rename", "append", "insert", "regex_replace", "name_style"}
)
# name_style 允许的风格值
_NAME_STYLE_VALUES: frozenset[str] = frozenset(
{"snake_case", "camelCase", "PascalCase", "kebab-case", "capitalize"}
) )
# regex_replace 允许的 flags 字符 # regex_replace 允许的 flags 字符
@@ -166,7 +173,7 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
action = action.strip().lower() action = action.strip().lower()
# ---------- path 校验 ---------- # ---------- path 校验 ----------
if action in {"set", "drop", "append", "insert", "regex_replace"}: if action in {"set", "drop", "append", "insert", "regex_replace", "name_style"}:
path = rule.get("path") path = rule.get("path")
if not isinstance(path, str) or not path.strip(): if not isinstance(path, str) or not path.strip():
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path") raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
@@ -222,6 +229,15 @@ def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
if not isinstance(count, int) or count < 0: if not isinstance(count, int) or count < 0:
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数") raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
# ---------- name_style 校验 ----------
if action == "name_style":
style = rule.get("style")
if not isinstance(style, str) or style not in _NAME_STYLE_VALUES:
raise ValueError(
f"body_rules[{idx}]: name_style 的 style 必须是 "
f"{sorted(_NAME_STYLE_VALUES)} 之一,当前值: {style!r}"
)
# ---------- condition 校验 ---------- # ---------- condition 校验 ----------
condition = rule.get("condition") condition = rule.get("condition")
if condition is not None: if condition is not None:

View File

@@ -989,3 +989,422 @@ class TestConditionalBodyRules:
], ],
) )
assert "feature" not in result assert "feature" not in result
class TestWildcardPaths:
"""通配符路径 [*] 和范围 [N-M] 的测试"""
def test_wildcard_set_all_elements(self) -> None:
body = {"tools": [{"name": "a"}, {"name": "b"}, {"name": "c"}]}
result = apply_body_rules(
body,
[{"action": "set", "path": "tools[*].enabled", "value": True}],
)
assert all(t["enabled"] is True for t in result["tools"])
def test_wildcard_regex_replace_all(self) -> None:
body = {"tools": [{"name": "get_user"}, {"name": "get_order"}, {"name": "set_config"}]}
result = apply_body_rules(
body,
[
{
"action": "regex_replace",
"path": "tools[*].name",
"pattern": "^get_",
"replacement": "fetch_",
}
],
)
assert result["tools"][0]["name"] == "fetch_user"
assert result["tools"][1]["name"] == "fetch_order"
assert result["tools"][2]["name"] == "set_config"
def test_wildcard_drop_all(self) -> None:
body = {"items": [{"a": 1, "b": 2}, {"a": 3, "b": 4}]}
result = apply_body_rules(
body,
[{"action": "drop", "path": "items[*].b"}],
)
assert result == {"items": [{"a": 1}, {"a": 3}]}
def test_wildcard_set_with_original_placeholder(self) -> None:
body = {"tools": [{"name": "foo"}, {"name": "bar"}]}
result = apply_body_rules(
body,
[
{
"action": "set",
"path": "tools[*].name",
"value": "prefix_{{$original}}",
}
],
)
assert result["tools"][0]["name"] == "prefix_foo"
assert result["tools"][1]["name"] == "prefix_bar"
def test_range_set_partial(self) -> None:
body = {"items": [{"v": 0}, {"v": 1}, {"v": 2}, {"v": 3}, {"v": 4}]}
result = apply_body_rules(
body,
[{"action": "set", "path": "items[1-3].v", "value": 99}],
)
assert result["items"][0]["v"] == 0
assert result["items"][1]["v"] == 99
assert result["items"][2]["v"] == 99
assert result["items"][3]["v"] == 99
assert result["items"][4]["v"] == 4
def test_range_exceeds_array_length(self) -> None:
body = {"items": [{"v": 0}, {"v": 1}]}
result = apply_body_rules(
body,
[{"action": "set", "path": "items[0-10].v", "value": 99}],
)
assert result["items"][0]["v"] == 99
assert result["items"][1]["v"] == 99
def test_wildcard_on_empty_array(self) -> None:
body: dict[str, Any] = {"tools": []}
result = apply_body_rules(
body,
[{"action": "set", "path": "tools[*].name", "value": "x"}],
)
assert result == {"tools": []}
def test_wildcard_on_non_array(self) -> None:
body = {"tools": "not_an_array"}
result = apply_body_rules(
body,
[{"action": "set", "path": "tools[*].name", "value": "x"}],
)
assert result == {"tools": "not_an_array"}
def test_wildcard_nested(self) -> None:
body = {
"data": [
{"items": [{"name": "a"}, {"name": "b"}]},
{"items": [{"name": "c"}]},
]
}
result = apply_body_rules(
body,
[{"action": "set", "path": "data[*].items[*].name", "value": "x"}],
)
assert result["data"][0]["items"][0]["name"] == "x"
assert result["data"][0]["items"][1]["name"] == "x"
assert result["data"][1]["items"][0]["name"] == "x"
def test_wildcard_append(self) -> None:
body = {"groups": [{"tags": ["a"]}, {"tags": ["b"]}]}
result = apply_body_rules(
body,
[{"action": "append", "path": "groups[*].tags", "value": "new"}],
)
assert result["groups"][0]["tags"] == ["a", "new"]
assert result["groups"][1]["tags"] == ["b", "new"]
def test_rename_with_wildcard_is_skipped(self) -> None:
"""rename 不支持通配符,应跳过"""
body = {"items": [{"old": 1}, {"old": 2}]}
result = apply_body_rules(
body,
[{"action": "rename", "from": "items[*].old", "to": "items[*].new"}],
)
assert result == {"items": [{"old": 1}, {"old": 2}]}
def test_wildcard_with_condition(self) -> None:
body = {"flag": True, "tools": [{"name": "a"}, {"name": "b"}]}
result = apply_body_rules(
body,
[
{
"action": "set",
"path": "tools[*].active",
"value": True,
"condition": {"path": "flag", "op": "eq", "value": True},
}
],
)
assert result["tools"][0]["active"] is True
assert result["tools"][1]["active"] is True
def test_does_not_mutate_original(self) -> None:
body = {"tools": [{"name": "a"}, {"name": "b"}]}
result = apply_body_rules(
body,
[{"action": "set", "path": "tools[*].name", "value": "x"}],
)
assert result["tools"][0]["name"] == "x"
assert body["tools"][0]["name"] == "a"
class TestNameStyleAction:
"""name_style action 的测试"""
def test_snake_case(self) -> None:
body = {"tools": [{"name": "getUserInfo"}, {"name": "setOrderStatus"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == "get_user_info"
assert result["tools"][1]["name"] == "set_order_status"
def test_camel_case(self) -> None:
body = {"tools": [{"name": "get_user_info"}, {"name": "set_order_status"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "camelCase"}],
)
assert result["tools"][0]["name"] == "getUserInfo"
assert result["tools"][1]["name"] == "setOrderStatus"
def test_pascal_case(self) -> None:
body = {"tools": [{"name": "get_user_info"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "PascalCase"}],
)
assert result["tools"][0]["name"] == "GetUserInfo"
def test_kebab_case(self) -> None:
body = {"tools": [{"name": "getUserInfo"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "kebab-case"}],
)
assert result["tools"][0]["name"] == "get-user-info"
def test_single_path_no_wildcard(self) -> None:
body = {"tool": {"name": "myFunctionName"}}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tool.name", "style": "snake_case"}],
)
assert result["tool"]["name"] == "my_function_name"
def test_invalid_style_skipped(self) -> None:
body = {"tools": [{"name": "foo"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "UPPER_CASE"}],
)
assert result["tools"][0]["name"] == "foo"
def test_non_string_value_skipped(self) -> None:
body = {"tools": [{"name": 123}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == 123
def test_already_correct_style(self) -> None:
body = {"tools": [{"name": "already_snake_case"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == "already_snake_case"
def test_mixed_styles_in_array(self) -> None:
body = {
"tools": [
{"name": "getUserInfo"},
{"name": "set_order_status"},
{"name": "DeleteItem"},
]
}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == "get_user_info"
assert result["tools"][1]["name"] == "set_order_status"
assert result["tools"][2]["name"] == "delete_item"
def test_with_numbers_in_name(self) -> None:
body = {"tools": [{"name": "getV2User"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == "get_v_2_user"
def test_does_not_mutate_original(self) -> None:
body = {"tools": [{"name": "getUserInfo"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "snake_case"}],
)
assert result["tools"][0]["name"] == "get_user_info"
assert body["tools"][0]["name"] == "getUserInfo"
def test_capitalize(self) -> None:
body = {"tools": [{"name": "writer"}, {"name": "edit"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[*].name", "style": "capitalize"}],
)
assert result["tools"][0]["name"] == "Writer"
assert result["tools"][1]["name"] == "Edit"
def test_capitalize_preserves_rest(self) -> None:
"""capitalize 只改首字母,保留其余部分"""
body = {"tools": [{"name": "getUserInfo"}]}
result = apply_body_rules(
body,
[{"action": "name_style", "path": "tools[0].name", "style": "capitalize"}],
)
assert result["tools"][0]["name"] == "GetUserInfo"
class TestItemCondition:
"""$item 条件引用的测试 -- 通配符路径下逐元素评估"""
def test_name_style_with_item_condition(self) -> None:
"""只对 name 在列表中的 tool 做首字母大写"""
body = {
"tools": [
{"name": "writer"},
{"name": "edit"},
{"name": "search"},
]
}
result = apply_body_rules(
body,
[
{
"action": "name_style",
"path": "tools[*].name",
"style": "capitalize",
"condition": {
"path": "$item.name",
"op": "in",
"value": ["writer", "edit"],
},
}
],
)
assert result["tools"][0]["name"] == "Writer"
assert result["tools"][1]["name"] == "Edit"
assert result["tools"][2]["name"] == "search" # 不在列表中,不变
def test_set_with_item_condition(self) -> None:
"""set + $item condition"""
body = {"items": [{"type": "a", "v": 1}, {"type": "b", "v": 2}, {"type": "a", "v": 3}]}
result = apply_body_rules(
body,
[
{
"action": "set",
"path": "items[*].v",
"value": 99,
"condition": {"path": "$item.type", "op": "eq", "value": "a"},
}
],
)
assert result["items"][0]["v"] == 99
assert result["items"][1]["v"] == 2 # type=b, 不变
assert result["items"][2]["v"] == 99
def test_drop_with_item_condition(self) -> None:
"""drop + $item condition"""
body = {
"tools": [
{"name": "a", "extra": 1},
{"name": "b", "extra": 2},
{"name": "c", "extra": 3},
]
}
result = apply_body_rules(
body,
[
{
"action": "drop",
"path": "tools[*].extra",
"condition": {"path": "$item.name", "op": "eq", "value": "b"},
}
],
)
assert "extra" in result["tools"][0]
assert "extra" not in result["tools"][1] # name=b, extra 被删除
assert "extra" in result["tools"][2]
def test_regex_replace_with_item_condition(self) -> None:
"""regex_replace + $item condition"""
body = {
"tools": [
{"name": "get_user", "type": "read"},
{"name": "set_user", "type": "write"},
]
}
result = apply_body_rules(
body,
[
{
"action": "regex_replace",
"path": "tools[*].name",
"pattern": "^get_",
"replacement": "fetch_",
"condition": {"path": "$item.type", "op": "eq", "value": "read"},
}
],
)
assert result["tools"][0]["name"] == "fetch_user"
assert result["tools"][1]["name"] == "set_user" # type=write, 不变
def test_item_condition_with_exists(self) -> None:
"""$item.xxx + exists"""
body = {"items": [{"name": "a"}, {"name": "b", "flag": True}, {"name": "c"}]}
result = apply_body_rules(
body,
[
{
"action": "set",
"path": "items[*].marked",
"value": True,
"condition": {"path": "$item.flag", "op": "exists"},
}
],
)
assert "marked" not in result["items"][0]
assert result["items"][1]["marked"] is True
assert "marked" not in result["items"][2]
def test_item_exact_ref(self) -> None:
"""$item (不带后缀) 引用整个元素"""
body = {"items": ["hello", 42, "world"]}
result = apply_body_rules(
body,
[
{
"action": "regex_replace",
"path": "items[*]",
"pattern": "^hello$",
"replacement": "hi",
"condition": {"path": "$item", "op": "type_is", "value": "string"},
}
],
)
assert result["items"][0] == "hi"
assert result["items"][1] == 42 # 不是 string跳过
assert result["items"][2] == "world"
def test_non_item_condition_still_global(self) -> None:
"""不含 $item 的 condition 仍然全局评估"""
body = {"flag": False, "tools": [{"name": "a"}, {"name": "b"}]}
result = apply_body_rules(
body,
[
{
"action": "set",
"path": "tools[*].active",
"value": True,
"condition": {"path": "flag", "op": "eq", "value": True},
}
],
)
# flag=False全局条件不满足所有元素都不变
assert "active" not in result["tools"][0]
assert "active" not in result["tools"][1]