diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts
index 567069b..c69eaa2 100644
--- a/frontend/bindings/voidraft/internal/services/models.ts
+++ b/frontend/bindings/voidraft/internal/services/models.ts
@@ -24,7 +24,7 @@ export class HttpRequest {
"headers": { [_: string]: string };
/**
- * json, formdata, urlencoded, text
+ * json, formdata, urlencoded, text, params, xml, html, javascript, binary
*/
"bodyType"?: string;
"body"?: any;
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar b/frontend/src/views/editor/extensions/httpclient/language/http.grammar
index ee4da15..e4f6316 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.grammar
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar
@@ -10,6 +10,11 @@
// @formdata - 表单数据(属性必须用逗号分隔)
// @urlencoded - URL 编码格式(属性必须用逗号分隔)
// @text - 纯文本内容
+// @params - URL 参数(用于 GET 请求)
+// @xml - XML 格式(固定 key: xml)
+// @html - HTML 格式(固定 key: html)
+// @javascript - JavaScript 格式(固定 key: javascript)
+// @binary - 二进制文件(固定 key: binary,值格式:@file 路径)
//
// 3. 变量定义:
// @var {
@@ -70,7 +75,52 @@
// }
// }
//
-// 示例 5 - 带响应数据:
+// 示例 5 - URL 参数请求:
+// GET "http://api.example.com/users" {
+// @params {
+// page: 1,
+// size: 20,
+// keyword: "张三"
+// }
+// }
+//
+// 示例 6 - XML 请求:
+// POST "http://api.example.com/soap" {
+// content-type: "application/xml"
+//
+// @xml {
+// xml: "张三25"
+// }
+// }
+//
+// 示例 7 - HTML 请求:
+// POST "http://api.example.com/render" {
+// content-type: "text/html"
+//
+// @html {
+// html: "
"
+// }
+// }
+//
+// 示例 8 - JavaScript 请求:
+// POST "http://api.example.com/execute" {
+// content-type: "application/javascript"
+//
+// @javascript {
+// javascript: "function hello() { return 'Hello World'; }"
+// }
+// }
+//
+// 示例 9 - 二进制文件上传:
+// POST "http://api.example.com/upload" {
+// content-type: "application/octet-stream"
+//
+// @binary {
+// binary: "@file E://Documents/avatar.png"
+// }
+// }
+//
+// 示例 10 - 带响应数据:
// POST "http://api.example.com/login" {
// @json {
// username: "admin",
@@ -120,14 +170,13 @@ ResponseDeclaration {
ResponseBlock
}
-// 响应状态:状态码(200 或 200-OK)或 "error" 关键字
-// 数字开头的状态码作为一个整体 token
+// 响应状态:数字或数字-标识符组合(200 或 200-OK)或 "error" 关键字
ResponseStatus {
- StatusCode |
+ (NumberLiteral ("-" identifier)?) |
@specialize[@name=ErrorStatus]
}
-// 响应时间:数字 + "ms" 作为一个整体 token
+// 响应时间:直接使用 TimeValue token
ResponseTime {
TimeValue
}
@@ -168,7 +217,12 @@ AtRule {
(JsonRule |
FormDataRule |
UrlEncodedRule |
- TextRule) ","?
+ TextRule |
+ ParamsRule |
+ XmlRule |
+ HtmlRule |
+ JavaScriptRule |
+ BinaryRule) ","?
}
// @json 块:JSON 格式请求体(属性必须用逗号分隔)
@@ -195,6 +249,36 @@ TextRule {
JsonBlock
}
+// @params 块:URL 参数(用于 GET 请求,属性必须用逗号分隔)
+ParamsRule {
+ @specialize[@name=ParamsKeyword]
+ JsonBlock
+}
+
+// @xml 块:XML 格式请求体(固定 key: xml)
+XmlRule {
+ @specialize[@name=XmlKeyword]
+ XmlBlock
+}
+
+// @html 块:HTML 格式请求体(固定 key: html)
+HtmlRule {
+ @specialize[@name=HtmlKeyword]
+ HtmlBlock
+}
+
+// @javascript 块:JavaScript 格式请求体(固定 key: javascript)
+JavaScriptRule {
+ @specialize[@name=JavaScriptKeyword]
+ JavaScriptBlock
+}
+
+// @binary 块:二进制文件(固定 key: binary,值格式:@file 路径)
+BinaryRule {
+ @specialize[@name=BinaryKeyword]
+ BinaryBlock
+}
+
// 普通块结构(属性逗号可选,最多一个请求体)
Block {
"{" blockContent? "}"
@@ -229,6 +313,30 @@ JsonProperty {
":" jsonValue
}
+// XML 块结构(可为空 {} 或必须包含 xml: value)
+XmlBlock {
+ "{" (@specialize[@name=XmlKey] ":" jsonValue) "}" |
+ "{" "}"
+}
+
+// HTML 块结构(可为空 {} 或必须包含 html: value)
+HtmlBlock {
+ "{" (@specialize[@name=HtmlKey] ":" jsonValue) "}" |
+ "{" "}"
+}
+
+// JavaScript 块结构(可为空 {} 或必须包含 javascript: value)
+JavaScriptBlock {
+ "{" (@specialize[@name=JavaScriptKey] ":" jsonValue) "}" |
+ "{" "}"
+}
+
+// Binary 块结构(可为空 {} 或必须包含 binary: value)
+BinaryBlock {
+ "{" (@specialize[@name=BinaryKey] ":" jsonValue) "}" |
+ "{" "}"
+}
+
// 值
NumberLiteral {
numberLiteralInner Unit?
@@ -328,19 +436,14 @@ JsonNull { @specialize[@name=Null] }
"T" @digit @digit ":" @digit @digit ":" @digit @digit
}
- // 状态码:纯数字或数字-字母组合(200, 200-OK, 404-Not-Found)
- StatusCode {
- @digit+ ("-" @asciiLetter (@asciiLetter | "-")*)?
- }
-
- // 时间值:数字 + ms(123ms)
- TimeValue {
+ // 时间值:数字 + ms,作为一个整体 token
+ TimeValue[isolate] {
@digit+ "ms"
}
-
+
whitespace { @whitespace+ }
- @precedence { Timestamp, TimeValue, StatusCode, numberLiteralInner, VariableRef, identifier, Unit }
+ @precedence { Timestamp, TimeValue, numberLiteralInner, VariableRef, identifier, Unit }
numberLiteralInner {
("+" | "-")? (@digit+ ("." @digit*)? | "." @digit+)
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.fixedkeys.test.ts b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.fixedkeys.test.ts
new file mode 100644
index 0000000..b49c956
--- /dev/null
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.fixedkeys.test.ts
@@ -0,0 +1,302 @@
+import { describe, it, expect } from 'vitest';
+import { parser } from './http.parser';
+
+describe('HTTP Grammar - 固定 Key 约束测试', () => {
+
+ function parseAndCheck(content: string, expectError: boolean = false) {
+ const tree = parser.parse(content);
+
+ console.log('\n=== 语法树结构 ===');
+ let hasError = false;
+ tree.iterate({
+ enter: (node) => {
+ const depth = getDepth(node.node);
+ const indent = ' '.repeat(depth);
+ const text = content.slice(node.from, node.to);
+ const preview = text.length > 60 ? text.slice(0, 60) + '...' : text;
+
+ if (node.name === '⚠') {
+ hasError = true;
+ console.log(`${indent}⚠ 错误节点 [${node.from}-${node.to}]: ${JSON.stringify(preview)}`);
+ } else {
+ console.log(`${indent}${node.name.padEnd(30 - depth * 2)} [${String(node.from).padStart(3)}-${String(node.to).padEnd(3)}]: ${JSON.stringify(preview)}`);
+ }
+ }
+ });
+
+ if (expectError) {
+ expect(hasError).toBe(true);
+ } else {
+ expect(hasError).toBe(false);
+ }
+
+ function getDepth(currentNode: any): number {
+ let depth = 0;
+ let node = currentNode;
+ while (node && node.parent) {
+ depth++;
+ node = node.parent;
+ }
+ return depth;
+ }
+ }
+
+ describe('✅ @xml - 正确使用固定 key', () => {
+ it('应该接受正确的 xml key', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml: "张三"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('应该拒绝错误的 key 名称', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ data: "张三"
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('应该拒绝多个属性', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml: "",
+ other: "value"
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+ });
+
+ describe('✅ @html - 正确使用固定 key', () => {
+ it('应该接受正确的 html key', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {
+ html: "标题
"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('应该拒绝错误的 key 名称', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {
+ content: "标题
"
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+ });
+
+ describe('✅ @javascript - 正确使用固定 key', () => {
+ it('应该接受正确的 javascript key', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {
+ javascript: "function hello() { return 'world'; }"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('应该拒绝错误的 key 名称', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {
+ code: "function hello() { return 'world'; }"
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+ });
+
+ describe('✅ @binary - 正确使用固定 key', () => {
+ it('应该接受正确的 binary key', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {
+ binary: "@file E://Documents/avatar.png"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('应该拒绝错误的 key 名称', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {
+ file: "@file E://Documents/avatar.png"
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+ });
+
+ describe('✅ 对比:@json 和 @params 允许任意 key', () => {
+ it('@json 可以使用任意 key 名称', () => {
+ const content = `POST "https://api.example.com/api" {
+ @json {
+ name: "张三",
+ age: 25,
+ email: "test@example.com"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('@params 可以使用任意 key 名称', () => {
+ const content = `GET "https://api.example.com/users" {
+ @params {
+ page: 1,
+ size: 20,
+ filter: "active"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+ });
+
+ describe('✅ 空块测试 - 现在支持空块', () => {
+ it('@xml 空块应该成功', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('@html 空块应该成功', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('@javascript 空块应该成功', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+
+ it('@binary 空块应该成功', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, false);
+ });
+ });
+
+ describe('❌ 定义了 key 但没有值应该报错', () => {
+ it('@xml 定义了 xml key 但没有值', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml:
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('@html 定义了 html key 但没有值', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {
+ html:
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('@javascript 定义了 javascript key 但没有值', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {
+ javascript:
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('@binary 定义了 binary key 但没有值', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {
+ binary:
+ }
+}`;
+
+ console.log('\n测试内容(应该有错误):');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+ });
+});
+
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.newformats.test.ts b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.newformats.test.ts
new file mode 100644
index 0000000..4d03b96
--- /dev/null
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.newformats.test.ts
@@ -0,0 +1,312 @@
+import { describe, it, expect } from 'vitest';
+import { parser } from './http.parser';
+
+describe('HTTP Grammar - 新增请求格式测试', () => {
+
+ function parseAndCheck(content: string, expectError: boolean = false) {
+ const tree = parser.parse(content);
+
+ console.log('\n=== 语法树结构 ===');
+ let hasError = false;
+ tree.iterate({
+ enter: (node) => {
+ const depth = getDepth(node.node);
+ const indent = ' '.repeat(depth);
+ const text = content.slice(node.from, node.to);
+ const preview = text.length > 60 ? text.slice(0, 60) + '...' : text;
+
+ if (node.name === '⚠') {
+ hasError = true;
+ console.log(`${indent}⚠ 错误节点 [${node.from}-${node.to}]: ${JSON.stringify(preview)}`);
+ } else {
+ console.log(`${indent}${node.name.padEnd(30 - depth * 2)} [${String(node.from).padStart(3)}-${String(node.to).padEnd(3)}]: ${JSON.stringify(preview)}`);
+ }
+ }
+ });
+
+ if (expectError) {
+ expect(hasError).toBe(true);
+ } else {
+ expect(hasError).toBe(false);
+ }
+
+ function getDepth(currentNode: any): number {
+ let depth = 0;
+ let node = currentNode;
+ while (node && node.parent) {
+ depth++;
+ node = node.parent;
+ }
+ return depth;
+ }
+ }
+
+ it('✅ @params - URL 参数', () => {
+ const content = `GET "https://api.example.com/users" {
+ @params {
+ page: 1,
+ size: 20,
+ keyword: "张三"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @xml - XML 格式请求体', () => {
+ const content = `POST "https://api.example.com/soap" {
+ content-type: "application/xml"
+
+ @xml {
+ xml: "张三25"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @html - HTML 格式请求体', () => {
+ const content = `POST "https://api.example.com/render" {
+ content-type: "text/html"
+
+ @html {
+ html: ""
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @javascript - JavaScript 格式请求体', () => {
+ const content = `POST "https://api.example.com/execute" {
+ content-type: "application/javascript"
+
+ @javascript {
+ javascript: "function hello() { return 'Hello World'; }"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @binary - 二进制文件上传', () => {
+ const content = `POST "https://api.example.com/upload" {
+ content-type: "application/octet-stream"
+
+ @binary {
+ binary: "@file E://Documents/avatar.png"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ 混合使用 - @params 和响应', () => {
+ const content = `GET "https://api.example.com/search" {
+ authorization: "Bearer token123"
+
+ @params {
+ q: "关键词",
+ page: 1,
+ limit: 50
+ }
+}
+
+@response 200-OK 156ms 2025-11-03T10:30:00 {
+ "total": 100,
+ "data": []
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ 复杂 XML 内容', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml: "123"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ 多行 JavaScript', () => {
+ const content = `POST "https://api.example.com/run" {
+ @javascript {
+ javascript: "function calculate(a, b) {\\n return a + b;\\n}"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @binary 支持不同路径格式', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {
+ binary: "@file C:/Users/Documents/file.pdf"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @params 支持空值', () => {
+ const content = `GET "https://api.example.com/list" {
+ @params {
+ filter: "",
+ page: 1
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @xml 空块', () => {
+ const content = `POST "https://api.example.com/soap" {
+ content-type: "application/xml"
+
+ @xml {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @html 空块', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @javascript 空块', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ @binary 空块', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {}
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('❌ @xml 定义了 key 但没有值应该报错', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml:
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('❌ @html 定义了 key 但没有值应该报错', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {
+ html:
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content, true);
+ });
+
+ it('✅ @xml 与其他格式混合', () => {
+ const content = `POST "https://api.example.com/multi" {
+ authorization: "Bearer token123"
+
+ @xml {
+ xml: "- test
"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+
+ it('✅ 多个请求使用不同格式', () => {
+ const content = `POST "https://api.example.com/xml" {
+ @xml {
+ xml: "张三"
+ }
+}
+
+POST "https://api.example.com/html" {
+ @html {
+ html: "内容
"
+ }
+}
+
+POST "https://api.example.com/js" {
+ @javascript {
+ javascript: "console.log('test');"
+ }
+}
+
+POST "https://api.example.com/binary" {
+ @binary {
+ binary: "@file C:/test.bin"
+ }
+}`;
+
+ console.log('\n测试内容:');
+ console.log(content);
+
+ parseAndCheck(content);
+ });
+});
+
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.response.test.ts b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.response.test.ts
index dea0d8f..3f9fe40 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.response.test.ts
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.response.test.ts
@@ -73,7 +73,7 @@ describe('HTTP Grammar - @response 响应语法', () => {
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
expect(hasNode(state, 'ErrorStatus')).toBe(true);
- expect(hasNode(state, 'TimeUnit')).toBe(true);
+ expect(hasNode(state, 'TimeValue')).toBe(true);
});
it('✅ 响应与请求结合', () => {
@@ -145,8 +145,8 @@ POST "https://api.example.com/users" {
const state = createTestState(content);
- expect(hasNode(state, 'TimeUnit')).toBe(true);
- expect(getNodeText(state, 'TimeUnit')).toBe('ms');
+ expect(hasNode(state, 'TimeValue')).toBe(true);
+ expect(getNodeText(state, 'TimeValue')).toBe('12345ms');
});
it('✅ 响应块包含复杂 JSON', () => {
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.test.ts b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.test.ts
index 29d1a9b..f9c0292 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.grammar.test.ts
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar.test.ts
@@ -88,7 +88,7 @@ describe('HTTP Grammar 解析测试', () => {
return depth;
}
- it('应该正确解析标准的 GET 请求(包含 @json 和 @res)', () => {
+ it('应该正确解析标准的 GET 请求(包含 @json)', () => {
const code = `GET "http://127.0.0.1:80/api/create" {
host: "https://api.example.com",
content-type: "application/json",
@@ -98,17 +98,6 @@ describe('HTTP Grammar 解析测试', () => {
name : "xxx",
test: "xx"
}
-
- @res {
- code: 200,
- status: "ok",
- size: "20kb",
- time: "2025-10-31 10:30:26",
- data: {
- xxx:"xxx"
-
- }
- }
}`;
const tree = parseCode(code);
@@ -277,7 +266,7 @@ POST "http://test2.com" {
expect(result.hasError).toBe(false);
});
- it('应该支持 @json/@res 块后面不加逗号(JSON块内部必须用逗号)', () => {
+ it('应该支持 @json 块后面不加逗号(JSON块内部必须用逗号)', () => {
const code = `POST "http://test.com" {
host: "test.com"
@@ -285,11 +274,6 @@ POST "http://test2.com" {
name: "xxx",
test: "xx"
}
-
- @res {
- code: 200,
- status: "ok"
- }
}`;
const tree = parseCode(code);
@@ -475,14 +459,6 @@ describe('HTTP 请求体格式测试', () => {
level: "advanced"
}
}
-
- @res {
- code: 200,
- message: "success",
- data: {
- id: 12345
- }
- }
}`;
const tree = parseCode(code);
@@ -509,12 +485,6 @@ describe('HTTP 请求体格式测试', () => {
age: 25,
description: "用户头像上传"
}
-
- @res {
- code: 200,
- message: "上传成功",
- url: "https://cdn.example.com/avatar.png"
- }
}`;
const tree = parseCode(code);
@@ -539,12 +509,6 @@ describe('HTTP 请求体格式测试', () => {
password: "123456",
remember: true
}
-
- @res {
- code: 200,
- message: "登录成功",
- token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
- }
}`;
const tree = parseCode(code);
@@ -593,13 +557,6 @@ POST "http://api.example.com/login" {
username: "admin",
password: "123456"
}
-
- # 期望的响应
- @res {
- code: 200,
- # 用户token
- token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
- }
}`;
const tree = parseCode(code);
@@ -615,7 +572,7 @@ POST "http://api.example.com/login" {
expect(result.hasError).toBe(false);
});
- it('✅ 混合多种格式 - JSON + 响应', () => {
+ it('✅ 混合多种格式 - JSON 请求', () => {
const code = `POST "http://api.example.com/login" {
content-type: "application/json"
user-agent: "Mozilla/5.0"
@@ -624,16 +581,6 @@ POST "http://api.example.com/login" {
username: "admin",
password: "123456"
}
-
- @res {
- code: 200,
- message: "登录成功",
- token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
- user: {
- id: 1,
- name: "管理员"
- }
- }
}`;
const tree = parseCode(code);
@@ -723,3 +670,442 @@ POST "http://api.example.com/login" {
});
});
+describe('HTTP 新格式测试 - params/xml/html/javascript/binary', () => {
+
+ function parseCode(code: string) {
+ const tree = parser.parse(code);
+ return tree;
+ }
+
+ function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
+ const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
+
+ tree.iterate({
+ enter: (node: any) => {
+ if (node.name === '⚠') {
+ errors.push({
+ name: node.name,
+ from: node.from,
+ to: node.to,
+ text: tree.toString().substring(node.from, node.to)
+ });
+ }
+ }
+ });
+
+ return {
+ hasError: errors.length > 0,
+ errors
+ };
+ }
+
+ it('✅ @params - URL 参数格式', () => {
+ const code = `GET "http://api.example.com/users" {
+ authorization: "Bearer token123"
+
+ @params {
+ page: 1,
+ size: 20,
+ keyword: "张三",
+ status: "active"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @params 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @xml - XML 格式', () => {
+ const code = `POST "http://api.example.com/soap" {
+ content-type: "application/xml"
+
+ @xml {
+ xml: "张三25"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @xml 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @xml - 空块', () => {
+ const code = `POST "http://api.example.com/soap" {
+ @xml {}
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @xml 空块格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @html - HTML 格式', () => {
+ const code = `POST "http://api.example.com/render" {
+ content-type: "text/html"
+
+ @html {
+ html: ""
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @html 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @html - 空块', () => {
+ const code = `POST "http://api.example.com/render" {
+ @html {}
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @html 空块格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @javascript - JavaScript 格式', () => {
+ const code = `POST "http://api.example.com/execute" {
+ content-type: "application/javascript"
+
+ @javascript {
+ javascript: "function hello() { return 'Hello World'; }"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @javascript 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @javascript - 空块', () => {
+ const code = `POST "http://api.example.com/execute" {
+ @javascript {}
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @javascript 空块格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @binary - 二进制文件上传', () => {
+ const code = `POST "http://api.example.com/upload" {
+ content-type: "application/octet-stream"
+
+ @binary {
+ binary: "@file E://Documents/avatar.png"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @binary 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ @binary - 空块', () => {
+ const code = `POST "http://api.example.com/upload" {
+ @binary {}
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ @binary 空块格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ 复杂 XML - SOAP 请求', () => {
+ const code = `POST "http://api.example.com/soap" {
+ @xml {
+ xml: "123"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ 复杂 XML 格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ 混合使用 - params + headers', () => {
+ const code = `GET "http://api.example.com/search" {
+ authorization: "Bearer token123"
+ user-agent: "Mozilla/5.0"
+
+ @params {
+ q: "搜索关键词",
+ page: 1,
+ limit: 50,
+ sort: "desc"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ 混合使用格式错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+ });
+
+ it('✅ 多个不同新格式的请求', () => {
+ const code = `# XML 请求
+POST "http://api.example.com/xml" {
+ @xml {
+ xml: "张三"
+ }
+}
+
+# HTML 请求
+POST "http://api.example.com/html" {
+ @html {
+ html: "内容
"
+ }
+}
+
+# JavaScript 请求
+POST "http://api.example.com/js" {
+ @javascript {
+ javascript: "console.log('test');"
+ }
+}
+
+# Binary 请求
+POST "http://api.example.com/upload" {
+ @binary {
+ binary: "@file C:/test.bin"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ 多新格式请求错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+
+ // 统计 RequestStatement 数量
+ let requestCount = 0;
+ tree.iterate({
+ enter: (node: any) => {
+ if (node.name === 'RequestStatement') requestCount++;
+ }
+ });
+
+ expect(requestCount).toBe(4);
+ });
+
+ it('❌ @xml - 定义了 xml key 但没有值(应该报错)', () => {
+ const code = `POST "http://api.example.com/soap" {
+ @xml {
+ xml:
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ // 应该有错误
+ expect(result.hasError).toBe(true);
+ });
+
+ it('❌ @html - 定义了 html key 但没有值(应该报错)', () => {
+ const code = `POST "http://api.example.com/render" {
+ @html {
+ html:
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ // 应该有错误
+ expect(result.hasError).toBe(true);
+ });
+
+ it('❌ @xml - 使用错误的 key 名称(应该报错)', () => {
+ const code = `POST "http://api.example.com/soap" {
+ @xml {
+ data: "张三"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ // 应该有错误
+ expect(result.hasError).toBe(true);
+ });
+
+ it('✅ 所有格式组合测试', () => {
+ const code = `# 传统格式
+POST "http://api.example.com/json" {
+ @json {
+ name: "test",
+ age: 25
+ }
+}
+
+POST "http://api.example.com/form" {
+ @formdata {
+ file: "test.png",
+ desc: "description"
+ }
+}
+
+POST "http://api.example.com/login" {
+ @urlencoded {
+ username: "admin",
+ password: "123456"
+ }
+}
+
+POST "http://api.example.com/text" {
+ @text {
+ content: "纯文本内容"
+ }
+}
+
+# 新格式
+GET "http://api.example.com/search" {
+ @params {
+ q: "keyword",
+ page: 1
+ }
+}
+
+POST "http://api.example.com/xml" {
+ @xml {
+ xml: "test"
+ }
+}
+
+POST "http://api.example.com/html" {
+ @html {
+ html: "test
"
+ }
+}
+
+POST "http://api.example.com/js" {
+ @javascript {
+ javascript: "alert('test');"
+ }
+}
+
+POST "http://api.example.com/upload" {
+ @binary {
+ binary: "@file C:/file.bin"
+ }
+}`;
+
+ const tree = parseCode(code);
+ const result = hasErrorNodes(tree);
+
+ if (result.hasError) {
+ console.log('\n❌ 所有格式组合测试错误:');
+ result.errors.forEach(err => {
+ console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
+ });
+ }
+
+ expect(result.hasError).toBe(false);
+
+ // 统计 RequestStatement 数量(应该有9个)
+ let requestCount = 0;
+ tree.iterate({
+ enter: (node: any) => {
+ if (node.name === 'RequestStatement') requestCount++;
+ }
+ });
+
+ expect(requestCount).toBe(9);
+ });
+});
+
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts b/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts
index 546f155..26d4f92 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts
@@ -18,12 +18,18 @@ export const httpHighlighting = styleTags({
"TRACE CONNECT": t.modifier,
// ========== @ 规则(请求体格式和变量声明)==========
- // @json, @formdata, @urlencoded - 使用类型名
- "JsonKeyword FormDataKeyword UrlEncodedKeyword": t.typeName,
+ // @json, @formdata, @urlencoded, @params - 使用类型名
+ "JsonKeyword FormDataKeyword UrlEncodedKeyword ParamsKeyword": t.typeName,
// @text - 使用特殊类型
"TextKeyword": t.special(t.typeName),
+ // @xml, @html, @javascript - 使用类型名
+ "XmlKeyword HtmlKeyword JavaScriptKeyword": t.typeName,
+
+ // @binary - 使用特殊类型
+ "BinaryKeyword": t.special(t.typeName),
+
// @var - 变量声明关键字
"VarKeyword": t.definitionKeyword,
@@ -60,15 +66,16 @@ export const httpHighlighting = styleTags({
// ========== 响应相关 ==========
// 响应状态码 - 数字颜色
- "StatusCode": t.number,
- "ResponseStatus/StatusCode": t.number,
+ "ResponseStatus/NumberLiteral": t.number,
+ "ResponseStatus/identifier": t.constant(t.variableName),
// 响应错误状态 - 关键字
"ErrorStatus": t.operatorKeyword,
- // 响应时间 - 数字颜色
+ // 响应时间 - 数字和单位颜色
"TimeValue": t.number,
- "ResponseTime": t.number,
+ "ResponseTime/TimeValue": t.number,
+ "TimeUnit": t.unit,
// 时间戳 - 字符串颜色
"Timestamp": t.string,
@@ -99,6 +106,12 @@ export const httpHighlighting = styleTags({
"JsonValue/StringLiteral": t.string,
"JsonValue/NumberLiteral": t.number,
+ // ========== 固定 key 名称(xml、html、javascript、binary)==========
+ "XmlKey": t.constant(t.propertyName),
+ "HtmlKey": t.constant(t.propertyName),
+ "JavaScriptKey": t.constant(t.propertyName),
+ "BinaryKey": t.constant(t.propertyName),
+
// ========== 标点符号 ==========
// 冒号 - 分隔符
":": t.separator,
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts b/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts
index dcd40f5..1389651 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts
@@ -40,17 +40,34 @@ export const
UrlEncodedKeyword = 44,
TextRule = 45,
TextKeyword = 46,
- ResponseDeclaration = 47,
- ResponseKeyword = 48,
- ResponseStatus = 49,
- StatusCode = 50,
- ErrorStatus = 51,
- ResponseTime = 52,
- TimeValue = 53,
- ResponseTimestamp = 54,
- Timestamp = 55,
- ResponseBlock = 56,
- JsonObject = 57,
- JsonMember = 58,
- JsonValue = 59,
- JsonArray = 62
+ ParamsRule = 47,
+ ParamsKeyword = 48,
+ XmlRule = 49,
+ XmlKeyword = 50,
+ XmlBlock = 51,
+ XmlKey = 52,
+ HtmlRule = 53,
+ HtmlKeyword = 54,
+ HtmlBlock = 55,
+ HtmlKey = 56,
+ JavaScriptRule = 57,
+ JavaScriptKeyword = 58,
+ JavaScriptBlock = 59,
+ JavaScriptKey = 60,
+ BinaryRule = 61,
+ BinaryKeyword = 62,
+ BinaryBlock = 63,
+ BinaryKey = 64,
+ ResponseDeclaration = 65,
+ ResponseKeyword = 66,
+ ResponseStatus = 67,
+ ErrorStatus = 68,
+ ResponseTime = 69,
+ TimeValue = 70,
+ ResponseTimestamp = 71,
+ Timestamp = 72,
+ ResponseBlock = 73,
+ JsonObject = 74,
+ JsonMember = 75,
+ JsonValue = 76,
+ JsonArray = 79
diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts b/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts
index 4d1a502..435d509 100644
--- a/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts
+++ b/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts
@@ -1,26 +1,26 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {httpHighlighting} from "./http.highlight"
-const spec_AtKeyword = {__proto__:null,"@var":10, "@json":80, "@formdata":84, "@urlencoded":88, "@text":92, "@response":96}
-const spec_identifier = {__proto__:null,true:34, false:38, null:42, GET:50, POST:52, PUT:54, DELETE:56, PATCH:58, HEAD:60, OPTIONS:62, CONNECT:64, TRACE:66, error:102}
+const spec_AtKeyword = {__proto__:null,"@var":10, "@json":80, "@formdata":84, "@urlencoded":88, "@text":92, "@params":96, "@xml":100, "@html":108, "@javascript":116, "@binary":124, "@response":132}
+const spec_identifier = {__proto__:null,true:34, false:38, null:42, GET:50, POST:52, PUT:54, DELETE:56, PATCH:58, HEAD:60, OPTIONS:62, CONNECT:64, TRACE:66, xml:104, html:112, javascript:120, binary:128, error:136}
export const parser = LRParser.deserialize({
version: 14,
- states: "-bQYQPOOO!aQPO'#C_OOQO'#Ct'#CtO!aQPO'#DTO!aQPO'#DVO!aQPO'#DXO!aQPO'#DZO!fQPO'#DSO#yQPO'#CsO$jQPO'#DlO$qQPO'#DgO$|QPO'#D]OOQO'#Du'#DuOOQO'#Dm'#DmQYQPOOO%UQPO'#CdOOQO,58y,58yOOQO,59o,59oOOQO,59q,59qOOQO,59s,59sOOQO,59u,59uOOQO,59n,59nOOQO'#DO'#DOO%^QPO,59_O%cQPO'#CiOOQO'#Cl'#ClOOQO'#Cn'#CnOOQO'#Cp'#CpO&QQPO'#D}OOQO,5:W,5:WO&YQPO,5:WOOQO'#Di'#DiO&_QPO'#DhO&dQPO'#D|OOQO,5:R,5:RO&lQPO,5:ROOQO'#D_'#D_O&qQPO,59wOOQO-E7k-E7kOOQO'#Cf'#CfO&vQPO'#CeO&{QPO'#DvOOQO,59O,59OO'TQPO,59OO'kQPO'#DPOOQO1G.y1G.yOOQO,59T,59TO'rQPO,5:iO'yQPO,5:iOOQO1G/r1G/rO$OQPO,5:SO(RQPO,5:hO(^QPO,5:hOOQO1G/m1G/mOOQO'#Db'#DbO(fQPO1G/cO(kQPO,59PO)SQPO,5:bO)[QPO,5:bOOQO1G.j1G.jOOQO'#DR'#DRO)dQPO'#DQOOQO'#Do'#DoO)iQPO'#DzO)pQPO'#DzOOQO,59k,59kO)xQPO,59kOOQO,5:[,5:[O)}QPO1G0TOOQO-E7n-E7nOOQO1G/n1G/nOOQO,5:],5:]O*UQPO1G0SOOQO-E7o-E7oOOQO'#Dd'#DdO*aQPO7+$}OOQO'#Dx'#DxOOQO1G.k1G.kOOQO,5:Y,5:YO*iQPO1G/|OOQO-E7l-E7lO*qQPO,59lOOQO-E7m-E7mO+SQPO,5:fO+SQPO,5:fOOQO1G/V1G/VP$OQPO'#DpP$tQPO'#DqOOQO'#Df'#DfOOQO< spec_AtKeyword[value] || -1},{term: 73, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
- tokenPrec: 503
+ specialized: [{term: 4, get: (value: keyof typeof spec_AtKeyword) => spec_AtKeyword[value] || -1},{term: 90, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
+ tokenPrec: 694
})
diff --git a/frontend/src/views/editor/extensions/httpclient/parser/request-parser.newformats.test.ts b/frontend/src/views/editor/extensions/httpclient/parser/request-parser.newformats.test.ts
new file mode 100644
index 0000000..4abcef6
--- /dev/null
+++ b/frontend/src/views/editor/extensions/httpclient/parser/request-parser.newformats.test.ts
@@ -0,0 +1,313 @@
+import { describe, it, expect } from 'vitest';
+import { EditorState } from '@codemirror/state';
+import { httpLanguage } from '../language';
+import { parseHttpRequest } from './request-parser';
+
+/**
+ * 创建测试用的 EditorState
+ */
+function createTestState(content: string): EditorState {
+ return EditorState.create({
+ doc: content,
+ extensions: [httpLanguage]
+ });
+}
+
+describe('HTTP Request Parser - 新格式测试', () => {
+
+ describe('✅ @params - URL 参数', () => {
+ it('应该正确解析 params 请求', () => {
+ const content = `GET "https://api.example.com/users" {
+ @params {
+ page: 1,
+ size: 20,
+ keyword: "张三"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('GET');
+ expect(request?.url).toBe('https://api.example.com/users');
+ expect(request?.bodyType).toBe('params');
+ expect(request?.body).toEqual({
+ page: 1,
+ size: 20,
+ keyword: '张三'
+ });
+ });
+ });
+
+ describe('✅ @xml - XML 格式', () => {
+ it('应该正确解析 xml 请求', () => {
+ const content = `POST "https://api.example.com/soap" {
+ content-type: "application/xml"
+
+ @xml {
+ xml: "张三25"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('POST');
+ expect(request?.bodyType).toBe('xml');
+ expect(request?.body).toEqual({
+ xml: '张三25'
+ });
+ expect(request?.headers['content-type']).toBe('application/xml');
+ });
+
+ it('应该正确解析空 xml 块', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {}
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('xml');
+ expect(request?.body).toEqual({});
+ });
+ });
+
+ describe('✅ @html - HTML 格式', () => {
+ it('应该正确解析 html 请求', () => {
+ const content = `POST "https://api.example.com/render" {
+ content-type: "text/html"
+
+ @html {
+ html: ""
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('POST');
+ expect(request?.bodyType).toBe('html');
+ expect(request?.body).toEqual({
+ html: ''
+ });
+ expect(request?.headers['content-type']).toBe('text/html');
+ });
+
+ it('应该正确解析空 html 块', () => {
+ const content = `POST "https://api.example.com/render" {
+ @html {}
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('html');
+ expect(request?.body).toEqual({});
+ });
+ });
+
+ describe('✅ @javascript - JavaScript 格式', () => {
+ it('应该正确解析 javascript 请求', () => {
+ const content = `POST "https://api.example.com/execute" {
+ content-type: "application/javascript"
+
+ @javascript {
+ javascript: "function hello() { return 'Hello World'; }"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('POST');
+ expect(request?.bodyType).toBe('javascript');
+ expect(request?.body).toEqual({
+ javascript: "function hello() { return 'Hello World'; }"
+ });
+ expect(request?.headers['content-type']).toBe('application/javascript');
+ });
+
+ it('应该正确解析空 javascript 块', () => {
+ const content = `POST "https://api.example.com/execute" {
+ @javascript {}
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('javascript');
+ expect(request?.body).toEqual({});
+ });
+ });
+
+ describe('✅ @binary - 二进制文件', () => {
+ it('应该正确解析 binary 请求', () => {
+ const content = `POST "https://api.example.com/upload" {
+ content-type: "application/octet-stream"
+
+ @binary {
+ binary: "@file E://Documents/avatar.png"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('POST');
+ expect(request?.bodyType).toBe('binary');
+ expect(request?.body).toEqual({
+ binary: '@file E://Documents/avatar.png'
+ });
+ expect(request?.headers['content-type']).toBe('application/octet-stream');
+ });
+
+ it('应该正确解析空 binary 块', () => {
+ const content = `POST "https://api.example.com/upload" {
+ @binary {}
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('binary');
+ expect(request?.body).toEqual({});
+ });
+ });
+
+ describe('✅ 混合使用场景', () => {
+ it('应该正确解析带 params 和 headers 的请求', () => {
+ const content = `GET "https://api.example.com/search" {
+ authorization: "Bearer token123"
+
+ @params {
+ q: "关键词",
+ page: 1,
+ limit: 50
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.method).toBe('GET');
+ expect(request?.headers['authorization']).toBe('Bearer token123');
+ expect(request?.bodyType).toBe('params');
+ expect(request?.body).toEqual({
+ q: '关键词',
+ page: 1,
+ limit: 50
+ });
+ });
+
+ it('应该正确解析复杂 XML 内容', () => {
+ const content = `POST "https://api.example.com/soap" {
+ @xml {
+ xml: "123"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('xml');
+ expect(request?.body.xml).toContain('soap:Envelope');
+ expect(request?.body.xml).toContain('GetUser');
+ });
+ });
+
+ describe('✅ 对比:传统格式仍然可用', () => {
+ it('JSON 格式仍然正常工作', () => {
+ const content = `POST "https://api.example.com/api" {
+ @json {
+ name: "张三",
+ age: 25,
+ email: "test@example.com"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('json');
+ expect(request?.body).toEqual({
+ name: '张三',
+ age: 25,
+ email: 'test@example.com'
+ });
+ });
+
+ it('FormData 格式仍然正常工作', () => {
+ const content = `POST "https://api.example.com/form" {
+ @formdata {
+ username: "admin",
+ password: "123456"
+ }
+}`;
+
+ const state = createTestState(content);
+ const request = parseHttpRequest(state, 0);
+
+ expect(request).not.toBeNull();
+ expect(request?.bodyType).toBe('formdata');
+ expect(request?.body).toEqual({
+ username: 'admin',
+ password: '123456'
+ });
+ });
+ });
+
+ describe('✅ 多个请求解析', () => {
+ it('应该能在同一文档中解析不同格式的请求', () => {
+ const content = `POST "https://api.example.com/xml" {
+ @xml {
+ xml: "张三"
+ }
+}
+
+POST "https://api.example.com/html" {
+ @html {
+ html: "内容
"
+ }
+}
+
+POST "https://api.example.com/js" {
+ @javascript {
+ javascript: "console.log('test');"
+ }
+}`;
+
+ const state = createTestState(content);
+
+ // 解析第一个请求(XML)
+ const request1 = parseHttpRequest(state, 0);
+ expect(request1?.bodyType).toBe('xml');
+ expect(request1?.body.xml).toContain('张三');
+
+ // 解析第二个请求(HTML)- 找到第二个 POST 的位置
+ const secondPostIndex = content.indexOf('POST', 10);
+ const request2 = parseHttpRequest(state, secondPostIndex);
+ expect(request2?.bodyType).toBe('html');
+ expect(request2?.body.html).toContain('内容');
+
+ // 解析第三个请求(JavaScript)
+ const thirdPostIndex = content.indexOf('POST', secondPostIndex + 10);
+ const request3 = parseHttpRequest(state, thirdPostIndex);
+ expect(request3?.bodyType).toBe('javascript');
+ expect(request3?.body.javascript).toContain('console.log');
+ });
+ });
+});
+
diff --git a/frontend/src/views/editor/extensions/httpclient/parser/request-parser.ts b/frontend/src/views/editor/extensions/httpclient/parser/request-parser.ts
index 793f14a..f0ac6c8 100644
--- a/frontend/src/views/editor/extensions/httpclient/parser/request-parser.ts
+++ b/frontend/src/views/editor/extensions/httpclient/parser/request-parser.ts
@@ -17,7 +17,7 @@ export interface HttpRequest {
headers: Record;
/** 请求体类型 */
- bodyType?: 'json' | 'formdata' | 'urlencoded' | 'text';
+ bodyType?: 'json' | 'formdata' | 'urlencoded' | 'text' | 'params' | 'xml' | 'html' | 'javascript' | 'binary';
/** 请求体内容 */
body?: any;
@@ -48,12 +48,30 @@ const NODE_TYPES = {
FORMDATA_RULE: 'FormDataRule',
URLENCODED_RULE: 'UrlEncodedRule',
TEXT_RULE: 'TextRule',
+ PARAMS_RULE: 'ParamsRule',
+ XML_RULE: 'XmlRule',
+ HTML_RULE: 'HtmlRule',
+ JAVASCRIPT_RULE: 'JavaScriptRule',
+ BINARY_RULE: 'BinaryRule',
JSON_KEYWORD: 'JsonKeyword',
FORMDATA_KEYWORD: 'FormDataKeyword',
URLENCODED_KEYWORD: 'UrlEncodedKeyword',
TEXT_KEYWORD: 'TextKeyword',
+ PARAMS_KEYWORD: 'ParamsKeyword',
+ XML_KEYWORD: 'XmlKeyword',
+ HTML_KEYWORD: 'HtmlKeyword',
+ JAVASCRIPT_KEYWORD: 'JavaScriptKeyword',
+ BINARY_KEYWORD: 'BinaryKeyword',
JSON_BLOCK: 'JsonBlock',
JSON_PROPERTY: 'JsonProperty',
+ XML_BLOCK: 'XmlBlock',
+ HTML_BLOCK: 'HtmlBlock',
+ JAVASCRIPT_BLOCK: 'JavaScriptBlock',
+ BINARY_BLOCK: 'BinaryBlock',
+ XML_KEY: 'XmlKey',
+ HTML_KEY: 'HtmlKey',
+ JAVASCRIPT_KEY: 'JavaScriptKey',
+ BINARY_KEY: 'BinaryKey',
VARIABLE_REF: 'VariableRef',
} as const;
@@ -220,16 +238,73 @@ export class HttpRequestParser {
[NODE_TYPES.FORMDATA_RULE]: 'formdata',
[NODE_TYPES.URLENCODED_RULE]: 'urlencoded',
[NODE_TYPES.TEXT_RULE]: 'text',
+ [NODE_TYPES.PARAMS_RULE]: 'params',
+ [NODE_TYPES.XML_RULE]: 'xml',
+ [NODE_TYPES.HTML_RULE]: 'html',
+ [NODE_TYPES.JAVASCRIPT_RULE]: 'javascript',
+ [NODE_TYPES.BINARY_RULE]: 'binary',
};
const type = typeMap[node.name];
- const blockNode = node.getChild(NODE_TYPES.JSON_BLOCK);
+
+ // 根据不同的规则类型解析不同的块
+ let content: any = null;
+
+ if (node.name === NODE_TYPES.XML_RULE) {
+ const blockNode = node.getChild(NODE_TYPES.XML_BLOCK);
+ content = blockNode ? this.parseFixedKeyBlock(blockNode, 'xml') : null;
+ } else if (node.name === NODE_TYPES.HTML_RULE) {
+ const blockNode = node.getChild(NODE_TYPES.HTML_BLOCK);
+ content = blockNode ? this.parseFixedKeyBlock(blockNode, 'html') : null;
+ } else if (node.name === NODE_TYPES.JAVASCRIPT_RULE) {
+ const blockNode = node.getChild(NODE_TYPES.JAVASCRIPT_BLOCK);
+ content = blockNode ? this.parseFixedKeyBlock(blockNode, 'javascript') : null;
+ } else if (node.name === NODE_TYPES.BINARY_RULE) {
+ const blockNode = node.getChild(NODE_TYPES.BINARY_BLOCK);
+ content = blockNode ? this.parseFixedKeyBlock(blockNode, 'binary') : null;
+ } else {
+ // json, formdata, urlencoded, text, params 使用 JsonBlock
+ const blockNode = node.getChild(NODE_TYPES.JSON_BLOCK);
+ content = blockNode ? this.parseJsonBlock(blockNode) : null;
+ }
return {
type,
- content: blockNode ? this.parseJsonBlock(blockNode) : null
+ content
};
}
+
+ /**
+ * 解析固定 key 的块(xml, html, javascript, binary)
+ * 格式:{ key: "value" } 或 {}(空块)
+ */
+ private parseFixedKeyBlock(node: SyntaxNode, keyName: string): any {
+ // 查找固定的 key 节点
+ const keyNode = node.getChild(
+ keyName === 'xml' ? NODE_TYPES.XML_KEY :
+ keyName === 'html' ? NODE_TYPES.HTML_KEY :
+ keyName === 'javascript' ? NODE_TYPES.JAVASCRIPT_KEY :
+ NODE_TYPES.BINARY_KEY
+ );
+
+ // 如果没有 key,返回空对象(支持空块)
+ if (!keyNode) {
+ return {};
+ }
+
+ // 查找值节点(冒号后面的内容)
+ let value: any = null;
+ for (let child = node.firstChild; child; child = child.nextSibling) {
+ if (child.name === NODE_TYPES.STRING_LITERAL ||
+ child.name === NODE_TYPES.VARIABLE_REF) {
+ value = this.parseValue(child);
+ break;
+ }
+ }
+
+ // 返回格式:{ xml: "value" } 或 { html: "value" } 等
+ return value !== null ? { [keyName]: value } : {};
+ }
/**
* 解析 JsonBlock(用于 @json, @form, @urlencoded)
diff --git a/internal/services/httpclient_service.go b/internal/services/httpclient_service.go
index 457f8dc..bd3d1c0 100644
--- a/internal/services/httpclient_service.go
+++ b/internal/services/httpclient_service.go
@@ -23,7 +23,7 @@ type HttpRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
- BodyType string `json:"bodyType,omitempty"` // json, formdata, urlencoded, text
+ BodyType string `json:"bodyType,omitempty"` // json, formdata, urlencoded, text, params, xml, html, javascript, binary
Body interface{} `json:"body,omitempty"`
}
@@ -89,6 +89,7 @@ func (hcs *HttpClientService) setRequestBody(req *resty.Request, request *HttpRe
case "json":
req.SetHeader("Content-Type", "application/json")
req.SetBody(request.Body)
+
case "formdata":
if formData, ok := request.Body.(map[string]interface{}); ok {
for key, value := range formData {
@@ -104,6 +105,7 @@ func (hcs *HttpClientService) setRequestBody(req *resty.Request, request *HttpRe
}
}
}
+
case "urlencoded":
req.SetHeader("Content-Type", "application/x-www-form-urlencoded")
if formData, ok := request.Body.(map[string]interface{}); ok {
@@ -113,9 +115,79 @@ func (hcs *HttpClientService) setRequestBody(req *resty.Request, request *HttpRe
}
req.SetBody(values.Encode())
}
+
case "text":
req.SetHeader("Content-Type", "text/plain")
req.SetBody(fmt.Sprintf("%v", request.Body))
+
+ case "params":
+ // URL 参数:添加到查询字符串中
+ if params, ok := request.Body.(map[string]interface{}); ok {
+ for key, value := range params {
+ req.SetQueryParam(key, fmt.Sprintf("%v", value))
+ }
+ }
+
+ case "xml":
+ // XML 格式:从 Body 中提取 xml 字段
+ req.SetHeader("Content-Type", "application/xml")
+ if bodyMap, ok := request.Body.(map[string]interface{}); ok {
+ if xmlContent, exists := bodyMap["xml"]; exists {
+ req.SetBody(fmt.Sprintf("%v", xmlContent))
+ } else {
+ return fmt.Errorf("xml body type requires 'xml' field")
+ }
+ } else {
+ return fmt.Errorf("xml body must be an object with 'xml' field")
+ }
+
+ case "html":
+ // HTML 格式:从 Body 中提取 html 字段
+ req.SetHeader("Content-Type", "text/html")
+ if bodyMap, ok := request.Body.(map[string]interface{}); ok {
+ if htmlContent, exists := bodyMap["html"]; exists {
+ req.SetBody(fmt.Sprintf("%v", htmlContent))
+ } else {
+ return fmt.Errorf("html body type requires 'html' field")
+ }
+ } else {
+ return fmt.Errorf("html body must be an object with 'html' field")
+ }
+
+ case "javascript":
+ // JavaScript 格式:从 Body 中提取 javascript 字段
+ req.SetHeader("Content-Type", "application/javascript")
+ if bodyMap, ok := request.Body.(map[string]interface{}); ok {
+ if jsContent, exists := bodyMap["javascript"]; exists {
+ req.SetBody(fmt.Sprintf("%v", jsContent))
+ } else {
+ return fmt.Errorf("javascript body type requires 'javascript' field")
+ }
+ } else {
+ return fmt.Errorf("javascript body must be an object with 'javascript' field")
+ }
+
+ case "binary":
+ // Binary 格式:从 Body 中提取 binary 字段(文件路径)
+ req.SetHeader("Content-Type", "application/octet-stream")
+ if bodyMap, ok := request.Body.(map[string]interface{}); ok {
+ if binaryContent, exists := bodyMap["binary"]; exists {
+ binaryStr := fmt.Sprintf("%v", binaryContent)
+ // 检查是否是文件类型,使用 @file 关键词
+ if strings.HasPrefix(binaryStr, "@file ") {
+ // 提取文件路径(去掉 @file 前缀)
+ filePath := strings.TrimSpace(strings.TrimPrefix(binaryStr, "@file "))
+ req.SetFile("file", filePath)
+ } else {
+ return fmt.Errorf("binary body requires '@file path/to/file' format")
+ }
+ } else {
+ return fmt.Errorf("binary body type requires 'binary' field")
+ }
+ } else {
+ return fmt.Errorf("binary body must be an object with 'binary' field")
+ }
+
default:
return fmt.Errorf("unsupported body type: %s", request.BodyType)
}
diff --git a/internal/services/httpclient_service_test.go b/internal/services/httpclient_service_test.go
new file mode 100644
index 0000000..2557e83
--- /dev/null
+++ b/internal/services/httpclient_service_test.go
@@ -0,0 +1,212 @@
+package services
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/wailsapp/wails/v3/pkg/services/log"
+)
+
+func TestHttpClientService_SetRequestBody(t *testing.T) {
+ logger := log.New()
+ service := NewHttpClientService(logger)
+
+ tests := []struct {
+ name string
+ request *HttpRequest
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "JSON 请求",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/json",
+ BodyType: "json",
+ Body: map[string]interface{}{
+ "name": "张三",
+ "age": 25,
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "XML 请求 - 正确格式",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/xml",
+ BodyType: "xml",
+ Body: map[string]interface{}{
+ "xml": "张三",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "XML 请求 - 缺少 xml 字段",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/xml",
+ BodyType: "xml",
+ Body: map[string]interface{}{
+ "data": "张三",
+ },
+ },
+ expectError: true,
+ errorMsg: "xml body type requires 'xml' field",
+ },
+ {
+ name: "HTML 请求 - 正确格式",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/html",
+ BodyType: "html",
+ Body: map[string]interface{}{
+ "html": "标题
",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "JavaScript 请求 - 正确格式",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/js",
+ BodyType: "javascript",
+ Body: map[string]interface{}{
+ "javascript": "function hello() { return 'world'; }",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Binary 请求 - 正确格式",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/upload",
+ BodyType: "binary",
+ Body: map[string]interface{}{
+ "binary": "@file C:/test.bin",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Binary 请求 - 错误格式(缺少 @file)",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/upload",
+ BodyType: "binary",
+ Body: map[string]interface{}{
+ "binary": "C:/test.bin",
+ },
+ },
+ expectError: true,
+ errorMsg: "binary body requires '@file path/to/file' format",
+ },
+ {
+ name: "Params 请求",
+ request: &HttpRequest{
+ Method: "GET",
+ URL: "https://api.example.com/users",
+ BodyType: "params",
+ Body: map[string]interface{}{
+ "page": 1,
+ "size": 20,
+ "query": "test",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "FormData 请求",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/form",
+ BodyType: "formdata",
+ Body: map[string]interface{}{
+ "username": "admin",
+ "password": "123456",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "UrlEncoded 请求",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/login",
+ BodyType: "urlencoded",
+ Body: map[string]interface{}{
+ "username": "admin",
+ "password": "123456",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Text 请求",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/text",
+ BodyType: "text",
+ Body: "纯文本内容",
+ },
+ expectError: false,
+ },
+ {
+ name: "不支持的 Body 类型",
+ request: &HttpRequest{
+ Method: "POST",
+ URL: "https://api.example.com/unknown",
+ BodyType: "unknown",
+ Body: "test",
+ },
+ expectError: true,
+ errorMsg: "unsupported body type: unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := service.client.R().SetContext(context.Background())
+ err := service.setRequestBody(req, tt.request)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ if tt.errorMsg != "" {
+ assert.Contains(t, err.Error(), tt.errorMsg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestHttpClientService_FormatBytes(t *testing.T) {
+ logger := log.New()
+ service := NewHttpClientService(logger)
+
+ tests := []struct {
+ name string
+ bytes int64
+ expected string
+ }{
+ {"负数", -1, "0 B"},
+ {"零字节", 0, "0 B"},
+ {"小于1KB", 500, "500 B"},
+ {"1KB", 1024, "1.0 KB"},
+ {"1MB", 1024 * 1024, "1.0 MB"},
+ {"1.5MB", 1024*1024 + 512*1024, "1.5 MB"},
+ {"1GB", 1024 * 1024 * 1024, "1.0 GB"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := service.formatBytes(tt.bytes)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}