mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 key,int 表示数组索引
|
@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 key,int 表示数组索引,_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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user