JAF 规范 v0.1
1. 概述
JAF (JSON API Flow) 是一种用于快速测试 JSON API 的轻量级脚本语言。支持变量、路径级联、登录态管理和响应断言。
使用场景: 快速开发迭代中的 JSON API 服务器功能验证,不适用于生产环境监控、性能测试、复杂集成测试。
设计原则:
- 理解、生成优先于执行
- 只支持 JSON,无其他格式
- 线性执行,无循环条件
- 人类可读,LLM 友好
- 够用即可,避免过度设计
- Fast-fail 策略,简化错误处理
- 脚本使用 UTF-8 编码
- 不处理数字精度和科学计数法(按宿主语言处理)
- 变量循环引用由具体实现处理
- 变量只支持 primitive 类型(string/number/boolean/null),不支持 object/array
- 变量替换为文本级 :变量值直接转为字符串插入 JSON,用户负责确保替换后为合法 JSON
- 指令严格单行,请求 JSON 可跨多行格式化
- 符号语义独立 :
?=在 JAF 中表示"默认值",与 Shell 等其他语言语义不同
2. 适用范围
支持:
- 请求:JSON body(自动序列化为 JSON 字符串),有限 header 注入
- 响应:JSON body,状态码,响应头读取(大小写敏感)
- 变量替换:
${ENV}、@{var}支持出现在 uri、header value、body 字段值中
不支持:
- 二进制 body、非 JSON 响应(视为错误)
- 文件上传、multipart/form-data、url-encoded-form
- Cookie 管理、重定向跟随、复杂认证流程(OAuth 等)
- 响应体大于内存、流式响应
3. 基础语法
3.1 四要素
jaf
#! 变量定义、默认值、路径前缀、session(单行)
#@ 单条请求配置(timeout/assert/ignore error)(单行)
#> 响应结构定义与捕获(单行)
{} HTTP 请求(JSON,可多行)
# 行内注释(单行)
3.2 注释
jaf
# 单行注释
#!var HOST = localhost:3000 # 行尾注释
4. 变量系统
4.1 变量定义
jaf
#!var HOST = localhost:3000 # 内部变量,脚本全局作用域
#!env API_KEY # 环境变量(必须存在,无默认值)
#!env TIMEOUT ?= 5000 # 环境变量带默认值(?= 表示若未设置则使用)
#!unset VAR # 删除变量
4.2 变量引用
| 语法 | 说明 | 作用域 | 生命周期 |
|---|---|---|---|
${ENV} |
环境变量 | 进程级 | 脚本执行期间 |
${ENV?=default} |
环境变量带默认值(JAF 语义:若未定义使用 default) | 进程级 | 脚本执行期间 |
@{var} |
上下文变量(字符串) | 脚本全局 | 脚本执行期间,跨请求 |
变量不可变规则:
- 变量一旦定义不可重新赋值(包括
#!var和捕获) - 捕获变量必须是新标识符,不能覆盖已有变量
- 环境变量
${ENV}不受此限制(外部可变更) - 变量只支持 primitive 类型(string/number/boolean/null),不支持 object/array
4.3 内置变量
实现应提供以下内置变量(只读,不可覆盖):
| 变量 | 说明 | 示例 |
|---|---|---|
@{_uuid_} |
UUID v4 字符串 | "550e8400-e29b-41d4-a716-446655440000" |
@{_timestamp_} |
Unix 时间戳(秒) | 1704067200 |
@{_timestamp_ms_} |
Unix 时间戳(毫秒) | 1704067200000 |
@{_iso_timestamp_} |
ISO 8601 格式 | "2024-01-15T10:30:00.000Z" |
@{_random_} |
0-1 随机小数 | 0.7423 |
5. 路径级联(#!base)
5.1 定义基础路径
jaf
#!base = http://api.com/v1
5.2 相对与绝对路径
| 形式 | 行为 | 示例 |
|---|---|---|
以 / 开头 |
相对 #!base 拼接 |
/users → http://api.com/v1/users |
以 http:// https:// 开头 |
绝对路径,忽略 base | https://other.com/api |
以 // 开头 |
协议相对 | //cdn.com/file |
| 纯变量 | 展开后判断:http(s)开头为绝对路径,其他相对#!base |
@{endpoint} |
5.3 URI 变量替换
jaf
{"uri":"/users/${API_VERSION}/@{userId}"} # 混合使用
{"uri":"https://@{host}/api"} # 变量作为域名
URL 编码: 变量替换后,路径组件进行 URL 编码(保留 / 作为分隔符,编码 ?、&、#、空格等,禁止或编码 ../ 序列)。绝对路径变量不编码但校验协议。
6. 默认值(#!default)
用途: 仅用于 HTTP 请求参数(method, headers),不涉及 uri、body、timeout
语法: 单行 JSON,支持变量替换
jaf
#!default {"method": "POST", "headers": {"content-type": "application/json"}}
与请求合并规则:
method:请求覆盖 default,请求无则使用 defaultheaders:深度合并,请求同 key 覆盖 defaulturi:请求必须提供,default 不提供 uribody:请求独有,default 不提供 body
覆盖规则: 单行覆盖,后续 #!default 完全替换前者
jaf
#!default {"method": "POST"} # 第一行
#!default {"headers": {"x-request-id": "@{_uuid_}"}} # 完全替换,method 恢复默认
清空全部:
jaf
#!default {} # 清空所有默认值,恢复内置默认
用户责任: 变量替换后必须是合法 JSON,否则报错停止。
7. 登录与 Session
7.1 Session 定义(延迟求值)
语法: 单行 JSON,多行累积合并
jaf
#!session {"headers": {"Authorization": "Bearer @{token}"}}
#!session {"headers": {"X-Custom": "value"}} # 累积合并,后者覆盖同 key
合并规则: 多行 #!session 累积,后续覆盖前相同 key
求值规则:
@{var}延迟求值 ,在#!session begin执行时才展开- 允许先定义会话,后捕获变量
- 若 begin 时变量未定义,报错停止
7.2 Session 控制
jaf
#!session begin # 进入 session,注入头部生效(此时求值)
#!session end # 退出 session,清除注入头部
禁止嵌套: 进入新会话前必须先结束当前会话,否则报错停止。
7.3 完整流程
jaf
# 1. 登录并捕获 token
{
"uri": "/auth",
"body": {
"user": "u",
"pass": "p"
}
}
#> capture {"$.token": "token"}
# 2. 定义会话(多行累积,token 可尚未定义)
#!session {"headers": {"Authorization": "Bearer @{token}"}}
#!session {"headers": {"X-Request-ID": "@{_uuid_}"}}
# 3. 进入会话(此时展开 token)
#!session begin
{"uri": "/posts"} # 自动带 Authorization 和 X-Request-ID
{"uri": "/profile"} # 同上
# 4. 退出会话
#!session end
{"uri": "/public"} # 无注入头部
# 5. 可重新进入(每次 begin 重新求值当前变量)
#!session begin
{"uri": "/new-post"}
#!session end
头部优先级(高→低):
- 请求内
headers字段 #!session注入头部#!default headers- 内置默认
8. 请求配置(#@)
jaf
#@ timeout 5000 # 超时毫秒(无默认值,不设置则使用实现默认)
#@ assert $status == 200 # 断言条件
#@ ignore error # 忽略当前请求错误(默认fast fail)
Fast-fail 策略:
| 场景 | 行为 |
|---|---|
| 网络不可达、DNS 失败 | 停止 |
| 超时 | 停止 |
| 非 JSON 响应 | 停止 |
| 状态码 4xx/5xx(无 ignore) | 停止 |
| 断言失败(无 ignore) | 停止 |
| 变量未定义 | 停止(语法错误) |
| Session begin 变量未定义 | 停止 |
| 类型转换失败 | 停止(或 ignore 时继续) |
#@ ignore error: 仅忽略当前请求的执行失败(网络/超时/状态码/断言),继续执行下一请求
断言不支持复合条件 (&& || !),通过多条 assert 实现:
jaf
#@ assert $status == 200
#@ assert $.id > 0
9. 响应定义(#>)
9.1 路径引用
| 前缀 | 含义 | 示例 |
|---|---|---|
$ 或 $body |
响应体根对象 | $, $.data.id |
$header. |
响应头(大小写敏感) | $header.Content-Type |
$status |
HTTP 状态码 | $status == 200 |
数组索引:
jaf
$.items[0] # 数组第0项
$.items[0].name # 嵌套访问
$.errors[0].code # 常用场景
简写规则: $. 是 $body. 的简写,$body 本身可单独使用表示整个响应体。
9.2 响应捕获(单行 JSON,多行累积)
语法: {"path": "var"} 或 {"path": "var:type"}
jaf
#> capture {"$.token": "auth_token"}
#> capture {"$.expires": "expires:number"}
#> capture {"$header.X-Request-ID": "rid"}
#> capture {"$.items[0].id": "firstId"}
合并规则: 多行累积,同 var 后者覆盖前者
9.3 响应期望(单行 JSON)
jaf
#> expect {"status": "ok", "code": <number>, "data": <object>}
9.4 类型系统
| 类型 | 说明 | 示例 |
|---|---|---|
string |
字符串 | "name": <string> |
number |
数字 | "count": <number> |
boolean |
布尔 | "active": <boolean> |
array |
数组 | "items": <array> |
object |
对象 | "data": <object> |
null |
空值 | "deleted": <null> |
any |
任意类型 | "meta": <any> |
string? |
可选字段 | "nickname": <string?> |
捕获规则:
- 捕获语法
{"path": "var:type"}尝试类型转换 - 捕获路径指向非 primitive 值(object/array)时报错停止
- 转换结果为 primitive 类型,赋值给变量
- 转换失败则失败(若
#@ ignore error则继续)
9.5 匹配规则
jaf
# 精确值匹配
#> expect {"status": "ok", "count": 5}
# 类型匹配
#> expect {"id": <string>, "count": <number>}
# 忽略其他字段
#> expect {"id": <string>, "...": "..."}
# 捕获与匹配组合(捕获先执行,匹配后执行)
#> capture {"$.id": "pid"}
#> expect {"status": "ok"}
执行顺序: 捕获始终先执行,匹配失败则停止(或忽略错误继续)
10. 请求格式
jaf
# body 字段必须是 JSON 对象/数组,运行时序列化为 JSON 字符串发送
# Content-Type 默认为 application/json(可通过 #!default 覆盖)
# 单行请求
{"uri": "/api", "body": {"key": "value"}}
# 多行请求(格式化 JSON)
{
"uri": "/api",
"body": {
"key": "value",
"nested": {
"data": [1, 2, 3]
}
}
}
# 不支持:
# - form-data(无文件上传)
# - x-www-form-urlencoded
# - 原始二进制
变量替换规则(文本级):
- 变量值(primitive)直接转为字符串插入
- 用户负责确保替换后为合法 JSON,否则报错停止
jaf
#!var name = "tom"
{"body": {"user": "@{name}"}} # 结果: {"user": "tom"}
#!var count = 123
{"body": {"total": @{count}}} # 结果: {"total": 123} (数字直接插入)
# 危险示例(用户责任):
#!var bad = "a\"b" # 含未转义引号
{"body": {"x": "@{bad}"}} # 结果: {"x": "a"b"} ← 非法JSON,报错
多行请求规则:
- 以
{或[开始的行启动请求解析 - 累积至匹配的
}或]结束 - 期间不允许空行、不允许注释、必须是合法 JSON
- 结束标志:匹配闭合符后的换行,下一行必须是新指令或新请求
11. 完整示例
jaf
# 场景:验证用户 CRUD
# 假设:API 基础路径由环境变量提供,默认 localhost:3000
#!base = http://${API_HOST?=localhost:3000}/api/v1
#!default {"method": "POST", "headers": {"content-type": "application/json"}}
# 创建用户(多行请求)
{
"uri": "/users",
"body": {
"name": "test",
"email": "@{_uuid_}@test.com"
}
}
#> capture {"$.id": "uid"}
#> capture {"$.createdAt": "created"}
#> expect {"status": "created"}
#@ assert $status == 201
# 获取用户(URI 变量替换)
{"uri": "/users/@{uid}"}
#> expect {"id": <string>, "name": "test"}
#@ assert $.id == @{uid}
# 更新(Session 注入认证头)
#!session {"headers": {"Authorization": "Bearer @{auth_token}"}}
#!session {"headers": {"X-Request-ID": "@{_uuid_}"}}
#!session begin
{
"uri": "/users/@{uid}",
"body": {"name": "updated"}
}
#@ assert $status == 200
#!session end
# 删除(允许 404,可能已被删除)
#@ ignore error
{"method": "DELETE", "uri": "/users/@{uid}"}
#@ assert $status == 204
#@ assert $status == 404
12. 语法速查
| 指令 | 用途 |
|---|---|
#!base = URL |
设置路径前缀 |
#!var NAME = VALUE |
定义内部变量(primitive) |
#!env NAME / #!env NAME ?= DEFAULT |
环境变量(?= 为 JAF 语义:默认值) |
#!unset NAME |
删除变量 |
#!default {...} |
设置 HTTP 请求默认值(单行,覆盖) |
#!default {} |
清空所有默认值 |
#!session {...} |
定义会话注入(单行,累积合并) |
#!session begin |
进入会话(求值并注入) |
#!session end |
退出会话(清除注入) |
#@ timeout MS |
设置超时(毫秒) |
#@ assert CONDITION |
断言检查 |
#@ ignore error |
忽略当前请求错误 |
#> capture {"path": "var"} |
响应捕获(单行,累积) |
#> capture {"path": "var:type"} |
带类型转换 |
#> expect {...} |
响应结构验证(单行) |
# COMMENT |
单行注释 |
路径规则
| 形式 | 行为 |
|---|---|
/path |
相对 #!base |
http://... https://... |
绝对路径 |
//... |
协议相对 |
$.items[0] |
数组索引 |
类型标记
| 语法 | 含义 |
|---|---|
<type> |
仅类型检查 |
{"path": "var"} |
捕获为字符串 |
{"path": "var:type"} |
捕获并类型转换 |
FIELD? |
可选字段 |
变量引用
| 语法 | 含义 |
|---|---|
${ENV} |
环境变量 |
${ENV?=default} |
环境变量,未定义时使用 default(JAF 语义) |
@{var} |
上下文变量 |
13. EBNF 语法
ebnf
JAF ::= { Line } EOF
Line ::= Directive | Request | Comment | Empty
Directive ::= "#!" ( Base | Var | Env | Unset | Default | Session )
| "#@" ( Timeout | Assert | IgnoreError )
| "#>" ( Capture | Expect )
Base ::= "base" "=" URI
Var ::= "var" IDENTIFIER "=" Primitive
Env ::= "env" IDENTIFIER [ "?=" Primitive ]
Unset ::= "unset" IDENTIFIER
Default ::= "default" JSON_Object
Session ::= "session" ( JSON_Object | "begin" | "end" )
Timeout ::= "timeout" NUMBER
Assert ::= "assert" Condition
IgnoreError ::= "ignore" "error"
Capture ::= "capture" Capture_Pair
Capture_Pair ::= "{" ( STRING | Path ) ":" Capture_Target "}"
Capture_Target::= IDENTIFIER [ ":" Type ]
Expect ::= "expect" JSON_Object
Request ::= JSON_Object | JSON_Array
JSON_Object ::= "{" [ JSON_Pair { "," JSON_Pair } ] "}"
JSON_Array ::= "[" [ JSON_Value { "," JSON_Value } ] "]"
JSON_Pair ::= STRING ":" JSON_Value
JSON_Value ::= STRING | NUMBER | BOOLEAN | NULL | JSON_Object | JSON_Array | Variable
Variable ::= "${" IDENTIFIER [ "?=" Primitive ] "}"
| "@{" ( IDENTIFIER | "_" IDENTIFIER "_" ) "}"
Path ::= "$" [ "body" ] { "." PathSegment }
| "$header." IDENTIFIER
| "$status"
PathSegment ::= IDENTIFIER | "[" NUMBER "]"
Type ::= "string" | "number" | "boolean" | "array" | "object" | "null" | "any" [ "?" ]
Condition ::= Comparable ( "==" | "!=" | ">" | "<" | ">=" | "<=" ) Comparable
Comparable ::= Path | Primitive
Primitive ::= STRING | NUMBER | BOOLEAN | NULL
Comment ::= "#" { CHAR } NEWLINE
Empty ::= NEWLINE
URI ::= STRING
IDENTIFIER ::= LETTER { LETTER | DIGIT | "_" } | "_" { LETTER | DIGIT | "_" }
NUMBER ::= DIGIT { DIGIT }
STRING ::= '"' { CHAR } '"'
BOOLEAN ::= "true" | "false"
NULL ::= "null"
CHAR ::= LETTER | DIGIT | SYMBOL | SPACE
LETTER ::= "a"..."z" | "A"..."Z"
DIGIT ::= "0"..."9"
SYMBOL ::= "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "-" | "+" | "=" | "[" | "]" | "{" | "}" | "|" | ";" | ":" | "," | "." | "<" | ">" | "/" | "?" | "~" | "`"
NEWLINE ::= "\n" | "\r\n"
EOF ::= /* 文件结束 */