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) + }) + } +}