mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: body_rules set 操作支持 {{$original}} 占位符引用原值
后端 request_builder 新增递归检测与解析逻辑,完全匹配时保留原始类型,
部分匹配时转为字符串拼接。前端 EndpointFormDialog 增加 sentinel 替换
机制,使含占位符的值能通过 JSON 校验,并在提交时还原为 {{$original}}。
This commit is contained in:
@@ -347,7 +347,7 @@
|
|||||||
<span class="text-muted-foreground text-xs">=</span>
|
<span class="text-muted-foreground text-xs">=</span>
|
||||||
<Input
|
<Input
|
||||||
:model-value="rule.value"
|
:model-value="rule.value"
|
||||||
placeholder="123 / "text" / [1,2]"
|
placeholder="123 / "text" / {{$original}}"
|
||||||
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, 'value', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||||
@@ -741,6 +741,65 @@ const RESERVED_BODY_FIELDS = new Set([
|
|||||||
'stream',
|
'stream',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// {{$original}} 占位符处理
|
||||||
|
const ORIGINAL_PLACEHOLDER = '{{$original}}'
|
||||||
|
const ORIGINAL_SENTINEL = '__AETHER_ORIGINAL__'
|
||||||
|
|
||||||
|
// 将 {{$original}} 替换为合法 JSON 以便 JSON.parse 校验
|
||||||
|
// 处理三种写法:裸占位符 {{$original}}、带引号 "{{$original}}"、引号内拼接 "prefix_{{$original}}_suffix"
|
||||||
|
function prepareValueForJsonParse(raw: string): string {
|
||||||
|
// Step 1: 纯文本替换占位符为 sentinel
|
||||||
|
const result = raw.replaceAll(ORIGINAL_PLACEHOLDER, ORIGINAL_SENTINEL)
|
||||||
|
|
||||||
|
// Step 2: 尝试直接 parse(占位符在引号内时已经是合法 JSON)
|
||||||
|
try { JSON.parse(result); return result } catch { /* sentinel not in valid JSON position */ }
|
||||||
|
|
||||||
|
// Step 3: 有裸 sentinel 不在引号内,需要扫描并补引号
|
||||||
|
let out = ''
|
||||||
|
let inStr = false
|
||||||
|
let i = 0
|
||||||
|
while (i < result.length) {
|
||||||
|
if (result[i] === '\\' && inStr) {
|
||||||
|
out += result[i] + (result[i + 1] || '')
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (result[i] === '"') {
|
||||||
|
inStr = !inStr
|
||||||
|
out += result[i]
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inStr && result.startsWith(ORIGINAL_SENTINEL, i)) {
|
||||||
|
out += '"' + ORIGINAL_SENTINEL + '"'
|
||||||
|
i += ORIGINAL_SENTINEL.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out += result[i]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归还原: 将 sentinel 字符串还原为 {{$original}}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function restoreOriginalPlaceholder(value: any): any {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value === ORIGINAL_SENTINEL) return ORIGINAL_PLACEHOLDER
|
||||||
|
if (value.includes(ORIGINAL_SENTINEL)) {
|
||||||
|
return value.replaceAll(ORIGINAL_SENTINEL, ORIGINAL_PLACEHOLDER)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(restoreOriginalPlaceholder)
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [k, v] of Object.entries(value)) result[k] = restoreOriginalPlaceholder(v)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
function parseBodyRulePathParts(path: string): string[] | null {
|
function parseBodyRulePathParts(path: string): string[] | null {
|
||||||
const raw = path.trim()
|
const raw = path.trim()
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@@ -1205,7 +1264,7 @@ function validateBodySetValue(rule: EditableBodyRule): string | null {
|
|||||||
const raw = rule.value.trim()
|
const raw = rule.value.trim()
|
||||||
if (!raw) return '值不能为空'
|
if (!raw) return '值不能为空'
|
||||||
try {
|
try {
|
||||||
JSON.parse(raw)
|
JSON.parse(prepareValueForJsonParse(raw))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
return `JSON 格式错误:${msg}`
|
return `JSON 格式错误:${msg}`
|
||||||
@@ -1219,7 +1278,7 @@ function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
|||||||
const raw = rule.value.trim()
|
const raw = rule.value.trim()
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
JSON.parse(raw)
|
JSON.parse(prepareValueForJsonParse(raw))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -1266,12 +1325,12 @@ function getBodySetValueValidationTip(rule: EditableBodyRule): string {
|
|||||||
const validation = getBodySetValueValidation(rule)
|
const validation = getBodySetValueValidation(rule)
|
||||||
if (validation === null) return '点击验证 JSON'
|
if (validation === null) return '点击验证 JSON'
|
||||||
if (validation === true) {
|
if (validation === true) {
|
||||||
const parsed = JSON.parse(rule.value.trim())
|
const parsed = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim())))
|
||||||
const type = Array.isArray(parsed) ? '数组' : typeof parsed === 'object' && parsed !== null ? '对象' : typeof parsed === 'string' ? '字符串' : typeof parsed === 'number' ? '数字' : typeof parsed === 'boolean' ? '布尔' : 'null'
|
const type = Array.isArray(parsed) ? '数组' : typeof parsed === 'object' && parsed !== null ? '对象' : typeof parsed === 'string' ? '字符串' : typeof parsed === 'number' ? '数字' : typeof parsed === 'boolean' ? '布尔' : 'null'
|
||||||
return `有效的 JSON (${type})`
|
return `有效的 JSON (${type})`
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
JSON.parse(rule.value.trim())
|
JSON.parse(prepareValueForJsonParse(rule.value.trim()))
|
||||||
return ''
|
return ''
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return err instanceof Error ? err.message : String(err)
|
return err instanceof Error ? err.message : String(err)
|
||||||
@@ -1399,7 +1458,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
|||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.action === 'set' && rule.path.trim()) {
|
if (rule.action === 'set' && rule.path.trim()) {
|
||||||
let value: any = rule.value
|
let value: any = rule.value
|
||||||
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
|
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||||
result.push({ action: 'set', path: rule.path.trim(), value })
|
result.push({ action: 'set', path: rule.path.trim(), value })
|
||||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||||
result.push({ action: 'drop', path: rule.path.trim() })
|
result.push({ action: 'drop', path: rule.path.trim() })
|
||||||
@@ -1407,7 +1466,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
|||||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||||
} else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
|
} else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
|
||||||
let value: any = rule.value
|
let value: any = rule.value
|
||||||
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
|
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||||
const indexStr = rule.index.trim()
|
const indexStr = rule.index.trim()
|
||||||
if (indexStr === '') {
|
if (indexStr === '') {
|
||||||
// 索引留空 → append 到末尾
|
// 索引留空 → append 到末尾
|
||||||
|
|||||||
@@ -503,6 +503,42 @@ def _extract_path(
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_PLACEHOLDER = "{{$original}}"
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_original_placeholder(value: Any) -> bool:
|
||||||
|
"""递归检查 value 中是否包含 {{$original}} 占位符"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _ORIGINAL_PLACEHOLDER in value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_contains_original_placeholder(v) for v in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_contains_original_placeholder(item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_original_placeholder(template: Any, original: Any) -> Any:
|
||||||
|
"""递归解析模板中的 {{$original}} 占位符。
|
||||||
|
|
||||||
|
- 字符串完全匹配 {{$original}} → 直接返回原值(保留原始类型)
|
||||||
|
- 字符串部分包含 {{$original}} → str(original) 插值
|
||||||
|
- dict → 递归处理每个 value
|
||||||
|
- list → 递归处理每个元素
|
||||||
|
- 其他 → 原样返回
|
||||||
|
"""
|
||||||
|
if isinstance(template, str):
|
||||||
|
if template == _ORIGINAL_PLACEHOLDER:
|
||||||
|
return original
|
||||||
|
if _ORIGINAL_PLACEHOLDER in template:
|
||||||
|
return template.replace(_ORIGINAL_PLACEHOLDER, str(original))
|
||||||
|
return template
|
||||||
|
if isinstance(template, dict):
|
||||||
|
return {k: _resolve_original_placeholder(v, original) for k, v in template.items()}
|
||||||
|
if isinstance(template, list):
|
||||||
|
return [_resolve_original_placeholder(item, original) for item in template]
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
def apply_body_rules(
|
def apply_body_rules(
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
rules: list[dict[str, Any]],
|
rules: list[dict[str, Any]],
|
||||||
@@ -521,6 +557,7 @@ def apply_body_rules(
|
|||||||
|
|
||||||
支持的规则类型:
|
支持的规则类型:
|
||||||
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
||||||
|
value 中的字符串 {{$original}} 会被替换为该路径的原值(完全匹配时保留类型)
|
||||||
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
||||||
- append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
- append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
||||||
@@ -557,7 +594,11 @@ def apply_body_rules(
|
|||||||
path = _extract_path(rule, protected_lower)
|
path = _extract_path(rule, protected_lower)
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
_set_nested_value(result, path, rule.get("value"))
|
value = rule.get("value")
|
||||||
|
if _contains_original_placeholder(value):
|
||||||
|
found, original = _get_nested_value(result, path)
|
||||||
|
value = _resolve_original_placeholder(value, original if found else None)
|
||||||
|
_set_nested_value(result, path, value)
|
||||||
|
|
||||||
elif action == "drop":
|
elif action == "drop":
|
||||||
path = _extract_path(rule, protected_lower)
|
path = _extract_path(rule, protected_lower)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ HeaderRule = dict[str, Any]
|
|||||||
# ========== Body Rule 类型定义 ==========
|
# ========== Body Rule 类型定义 ==========
|
||||||
# 请求体规则支持六种操作:
|
# 请求体规则支持六种操作:
|
||||||
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
|
# value 中的字符串 {{$original}} 会被替换为该路径的原值(完全匹配时保留类型)
|
||||||
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
# - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
# - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from src.api.handlers.base.request_builder import apply_body_rules
|
from src.api.handlers.base.request_builder import apply_body_rules
|
||||||
|
|
||||||
|
|
||||||
@@ -93,3 +95,126 @@ class TestApplyBodyRulesNestedPaths:
|
|||||||
result = apply_body_rules(body, [{"action": "set", "path": "a.c", "value": 2}])
|
result = apply_body_rules(body, [{"action": "set", "path": "a.c", "value": 2}])
|
||||||
assert result == {"a": {"b": 1, "c": 2}}
|
assert result == {"a": {"b": 1, "c": 2}}
|
||||||
assert body == {"a": {"b": 1}}
|
assert body == {"a": {"b": 1}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetWithOriginalPlaceholder:
|
||||||
|
"""set 操作中 {{$original}} 占位符的测试"""
|
||||||
|
|
||||||
|
def test_wrap_string_in_object(self) -> None:
|
||||||
|
"""核心用例:字符串值包裹成对象结构"""
|
||||||
|
body = {"para": "text"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "para", "value": [{"text": "{{$original}}"}]}],
|
||||||
|
)
|
||||||
|
assert result == {"para": [{"text": "text"}]}
|
||||||
|
|
||||||
|
def test_exact_placeholder_preserves_number(self) -> None:
|
||||||
|
"""完全匹配占位符时保留原始类型: number"""
|
||||||
|
body = {"count": 42}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "count", "value": {"num": "{{$original}}"}}],
|
||||||
|
)
|
||||||
|
assert result == {"count": {"num": 42}}
|
||||||
|
assert isinstance(result["count"]["num"], int)
|
||||||
|
|
||||||
|
def test_exact_placeholder_preserves_dict(self) -> None:
|
||||||
|
"""完全匹配占位符时保留原始类型: dict"""
|
||||||
|
body = {"data": {"key": "val"}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "data", "value": {"wrapped": "{{$original}}"}}],
|
||||||
|
)
|
||||||
|
assert result == {"data": {"wrapped": {"key": "val"}}}
|
||||||
|
|
||||||
|
def test_exact_placeholder_preserves_list(self) -> None:
|
||||||
|
"""完全匹配占位符时保留原始类型: list"""
|
||||||
|
body = {"items": [1, 2, 3]}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "items", "value": {"arr": "{{$original}}"}}],
|
||||||
|
)
|
||||||
|
assert result == {"items": {"arr": [1, 2, 3]}}
|
||||||
|
|
||||||
|
def test_partial_placeholder_converts_to_string(self) -> None:
|
||||||
|
"""部分匹配时将原值转为 str 拼接"""
|
||||||
|
body = {"name": "world"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "name", "value": "hello_{{$original}}_suffix"}],
|
||||||
|
)
|
||||||
|
assert result == {"name": "hello_world_suffix"}
|
||||||
|
|
||||||
|
def test_partial_placeholder_with_number(self) -> None:
|
||||||
|
"""部分匹配 + 非字符串原值: 数字转 str"""
|
||||||
|
body = {"ver": 3}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "ver", "value": "version_{{$original}}"}],
|
||||||
|
)
|
||||||
|
assert result == {"ver": "version_3"}
|
||||||
|
|
||||||
|
def test_no_placeholder_acts_like_plain_set(self) -> None:
|
||||||
|
"""不含占位符时行为与原 set 完全一致"""
|
||||||
|
body = {"a": 1}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "a", "value": {"new": "val"}}],
|
||||||
|
)
|
||||||
|
assert result == {"a": {"new": "val"}}
|
||||||
|
|
||||||
|
def test_missing_path_uses_none(self) -> None:
|
||||||
|
"""路径不存在时 original 为 None"""
|
||||||
|
body: dict[str, Any] = {}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "missing", "value": {"was": "{{$original}}"}}],
|
||||||
|
)
|
||||||
|
assert result == {"missing": {"was": None}}
|
||||||
|
|
||||||
|
def test_nested_path(self) -> None:
|
||||||
|
"""嵌套路径"""
|
||||||
|
body = {"a": {"b": "original"}}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "a.b", "value": [{"content": "{{$original}}"}]}],
|
||||||
|
)
|
||||||
|
assert result == {"a": {"b": [{"content": "original"}]}}
|
||||||
|
|
||||||
|
def test_protected_field_ignored(self) -> None:
|
||||||
|
"""受保护字段(model, stream)跳过"""
|
||||||
|
body = {"model": "gpt-4", "other": "val"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "model", "value": "{{$original}}_modified"}],
|
||||||
|
)
|
||||||
|
assert result["model"] == "gpt-4"
|
||||||
|
|
||||||
|
def test_does_not_mutate_original(self) -> None:
|
||||||
|
"""不修改原始 body"""
|
||||||
|
body = {"x": "original"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "x", "value": {"wrapped": "{{$original}}"}}],
|
||||||
|
)
|
||||||
|
assert result == {"x": {"wrapped": "original"}}
|
||||||
|
assert body == {"x": "original"}
|
||||||
|
|
||||||
|
def test_multiple_placeholders_in_one_string(self) -> None:
|
||||||
|
"""一个字符串中多个占位符"""
|
||||||
|
body = {"val": "X"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "val", "value": "{{$original}}-{{$original}}"}],
|
||||||
|
)
|
||||||
|
assert result == {"val": "X-X"}
|
||||||
|
|
||||||
|
def test_deeply_nested_template(self) -> None:
|
||||||
|
"""深层嵌套模板"""
|
||||||
|
body = {"data": "hello"}
|
||||||
|
result = apply_body_rules(
|
||||||
|
body,
|
||||||
|
[{"action": "set", "path": "data", "value": {"a": {"b": [{"c": "{{$original}}"}]}}}],
|
||||||
|
)
|
||||||
|
assert result == {"data": {"a": {"b": [{"c": "hello"}]}}}
|
||||||
|
|||||||
Reference in New Issue
Block a user