Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbb6586fa | ||
|
|
a043b24f69 | ||
|
|
4d342b9198 | ||
|
|
a6a859c566 | ||
|
|
a2afd947d0 | ||
|
|
cfde1b4306 | ||
|
|
0a4e5d50f3 | ||
|
|
ed55ce758e | ||
|
|
22e8dd4548 | ||
|
|
a702746df7 | ||
|
|
a57ce2e77a | ||
|
|
7a08c3142a | ||
|
|
362015c1cf | ||
|
|
0969901481 | ||
|
|
6bddfc3731 | ||
|
|
26ac804b1d | ||
|
|
56299b5485 |
@@ -20,6 +20,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
|
|||||||
- 🗄️ **Database** - SQLite integration for data persistence
|
- 🗄️ **Database** - SQLite integration for data persistence
|
||||||
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
||||||
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
|
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
|
||||||
|
- 📖 **Swagger UI** - Interactive API documentation with OpenAPI 3.0/3.1 support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -50,8 +51,13 @@ cd v-hono
|
|||||||
### Standard Build (picoev backend)
|
### Standard Build (picoev backend)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
v your_app.v -o app.exe
|
# macOS / Linux
|
||||||
./app.exe
|
v -prod -o app your_app.v
|
||||||
|
./app
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
v -o app.exe your_app.v
|
||||||
|
.\app.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
### High-Performance Build (uSockets backend)
|
### High-Performance Build (uSockets backend)
|
||||||
@@ -59,42 +65,21 @@ v your_app.v -o app.exe
|
|||||||
For maximum performance with uSockets backend:
|
For maximum performance with uSockets backend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
v -enable-globals -cc gcc -ldflags "-ldbghelp" your_app.v -o app.exe
|
# macOS / Linux
|
||||||
./app.exe
|
v -enable-globals -prod -o app your_app.v
|
||||||
|
./app
|
||||||
|
|
||||||
|
# Windows (must use gcc, tcc does not support .a static library format)
|
||||||
|
v -enable-globals -cc gcc -ldflags "-ldbghelp" -o app.exe your_app.v
|
||||||
|
.\app.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: The `-enable-globals` flag is required for uSockets backend. The `-ldflags "-ldbghelp"` fixes Windows linking issues.
|
**Important Notes:**
|
||||||
|
- The `-enable-globals` flag is required for uSockets backend
|
||||||
|
- On Windows, you **must** use `-cc gcc` because V's default compiler (tcc) does not support MinGW `.a` static library format
|
||||||
|
- The `-ldflags "-ldbghelp"` is required on Windows for libuv linking
|
||||||
|
|
||||||
#### Windows Prerequisites
|
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/{platform}/` directory. See [usockets/README.md](usockets/README.md) for prerequisites, building from source, and detailed configuration options.
|
||||||
|
|
||||||
To use uSockets backend on Windows, you need:
|
|
||||||
|
|
||||||
1. **GCC (MinGW-w64)** - C compiler
|
|
||||||
```powershell
|
|
||||||
# Install via Scoop
|
|
||||||
scoop install mingw
|
|
||||||
|
|
||||||
# Or via Chocolatey
|
|
||||||
choco install mingw
|
|
||||||
|
|
||||||
# Or download from: https://www.mingw-w64.org/downloads/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **V Compiler** - Version 0.4.x or later
|
|
||||||
```powershell
|
|
||||||
# Install via Scoop
|
|
||||||
scoop install vlang
|
|
||||||
|
|
||||||
# Or download from: https://vlang.io/
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify Installation**
|
|
||||||
```powershell
|
|
||||||
gcc --version # Should show MinGW-w64 GCC
|
|
||||||
v version # Should show V 0.4.x
|
|
||||||
```
|
|
||||||
|
|
||||||
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/` directory.
|
|
||||||
|
|
||||||
### Using uSockets Backend
|
### Using uSockets Backend
|
||||||
|
|
||||||
@@ -1030,6 +1015,201 @@ eventSource.onerror = (e) => {
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 10. Swagger UI
|
||||||
|
|
||||||
|
Interactive API documentation with OpenAPI 3.0/3.1 specification support.
|
||||||
|
|
||||||
|
#### Basic Usage
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// Build OpenAPI specification using the fluent builder API
|
||||||
|
spec := hono.OpenAPIBuilder.new()
|
||||||
|
.openapi('3.0.0')
|
||||||
|
.title('My API')
|
||||||
|
.version('1.0.0')
|
||||||
|
.description('API documentation')
|
||||||
|
.server('http://localhost:3000', 'Development server')
|
||||||
|
.path('/users')
|
||||||
|
.get(hono.OpenAPIOperation{
|
||||||
|
summary: 'List all users'
|
||||||
|
tags: ['users']
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'A list of users'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.done()
|
||||||
|
.build() or {
|
||||||
|
eprintln('Failed to build spec: ${err}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register OpenAPI JSON endpoint
|
||||||
|
app.doc('/doc', spec)
|
||||||
|
|
||||||
|
// Serve Swagger UI
|
||||||
|
app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'My API Documentation'
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OpenAPI Builder API
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
// Build a complete OpenAPI specification
|
||||||
|
spec := hono.OpenAPIBuilder.new()
|
||||||
|
.openapi('3.0.0') // OpenAPI version
|
||||||
|
.title('Pet Store API') // API title
|
||||||
|
.version('1.0.0') // API version
|
||||||
|
.description('A sample API') // Description
|
||||||
|
.server('https://api.example.com', 'Production') // Server URL
|
||||||
|
.tag('pets', 'Pet operations') // Tag definition
|
||||||
|
// Define path with multiple operations
|
||||||
|
.path('/pets')
|
||||||
|
.get(hono.OpenAPIOperation{
|
||||||
|
summary: 'List pets'
|
||||||
|
operation_id: 'listPets'
|
||||||
|
tags: ['pets']
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'limit'
|
||||||
|
in_location: 'query'
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'array'
|
||||||
|
items: &hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/Pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post(hono.OpenAPIOperation{
|
||||||
|
summary: 'Create pet'
|
||||||
|
request_body: hono.OpenAPIRequestBody{
|
||||||
|
required: true
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/NewPet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
'201': hono.OpenAPIResponse{
|
||||||
|
description: 'Created'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.done()
|
||||||
|
// Add reusable schemas
|
||||||
|
.add_schema('Pet', hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
required: ['id', 'name']
|
||||||
|
properties: {
|
||||||
|
'id': hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
}
|
||||||
|
'name': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Swagger UI Options
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
// Customize Swagger UI appearance and behavior
|
||||||
|
app.get('/docs', hono.swagger_ui(hono.SwaggerUIOptions{
|
||||||
|
url: '/doc' // OpenAPI JSON URL
|
||||||
|
title: 'API Documentation' // Page title
|
||||||
|
deep_linking: true // Enable deep linking
|
||||||
|
display_request_duration: true // Show request duration
|
||||||
|
default_models_expand_depth: 2 // Model expansion depth
|
||||||
|
doc_expansion: 'list' // 'list', 'full', or 'none'
|
||||||
|
filter: true // Enable filtering
|
||||||
|
show_extensions: true // Show extensions
|
||||||
|
show_common_extensions: true // Show common extensions
|
||||||
|
try_it_out_enabled: true // Enable Try it out
|
||||||
|
custom_css: '.topbar { background: #2c3e50; }' // Custom CSS
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SwaggerUIOptions Reference
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `url` | `string` | `'/doc'` | OpenAPI document URL |
|
||||||
|
| `title` | `string` | `'API Documentation'` | Page title |
|
||||||
|
| `deep_linking` | `bool` | `true` | Enable deep linking to operations |
|
||||||
|
| `display_request_duration` | `bool` | `true` | Show request duration |
|
||||||
|
| `default_models_expand_depth` | `int` | `1` | Model expansion depth |
|
||||||
|
| `doc_expansion` | `string` | `'list'` | Document expansion: 'list', 'full', 'none' |
|
||||||
|
| `filter` | `bool` | `false` | Enable operation filtering |
|
||||||
|
| `show_extensions` | `bool` | `false` | Show vendor extensions |
|
||||||
|
| `show_common_extensions` | `bool` | `true` | Show common extensions |
|
||||||
|
| `try_it_out_enabled` | `bool` | `true` | Enable Try it out feature |
|
||||||
|
| `custom_css` | `string` | `''` | Custom CSS styles |
|
||||||
|
| `custom_js` | `string` | `''` | Custom JavaScript |
|
||||||
|
| `custom_css_url` | `string` | `''` | Custom CSS URL |
|
||||||
|
| `custom_js_url` | `string` | `''` | Custom JavaScript URL |
|
||||||
|
|
||||||
|
#### OpenAPI Builder Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `openapi(version)` | Set OpenAPI version (3.0.0, 3.1.0) |
|
||||||
|
| `title(title)` | Set API title |
|
||||||
|
| `version(version)` | Set API version |
|
||||||
|
| `description(desc)` | Set API description |
|
||||||
|
| `server(url, desc)` | Add server |
|
||||||
|
| `tag(name, desc)` | Add tag |
|
||||||
|
| `path(path)` | Start path builder |
|
||||||
|
| `add_schema(name, schema)` | Add reusable schema |
|
||||||
|
| `add_security_scheme(name, scheme)` | Add security scheme |
|
||||||
|
| `build()` | Build and validate document |
|
||||||
|
|
||||||
|
#### Path Builder Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `get(op)` | Add GET operation |
|
||||||
|
| `post(op)` | Add POST operation |
|
||||||
|
| `put(op)` | Add PUT operation |
|
||||||
|
| `delete(op)` | Add DELETE operation |
|
||||||
|
| `patch(op)` | Add PATCH operation |
|
||||||
|
| `head(op)` | Add HEAD operation |
|
||||||
|
| `options(op)` | Add OPTIONS operation |
|
||||||
|
| `done()` | Return to parent builder |
|
||||||
|
|
||||||
## Route Grouping
|
## Route Grouping
|
||||||
|
|
||||||
```v
|
```v
|
||||||
@@ -1136,6 +1316,8 @@ v-hono/
|
|||||||
├── validator.v # Request validation
|
├── validator.v # Request validation
|
||||||
├── websocket.v # WebSocket helper (RFC 6455)
|
├── websocket.v # WebSocket helper (RFC 6455)
|
||||||
├── streaming.v # SSE streaming helper
|
├── streaming.v # SSE streaming helper
|
||||||
|
├── swagger.v # Swagger UI middleware
|
||||||
|
├── openapi.v # OpenAPI 3.0/3.1 data structures and builder
|
||||||
├── picoev_server.v # Picoev backend with WebSocket/SSE support
|
├── picoev_server.v # Picoev backend with WebSocket/SSE support
|
||||||
├── usockets_server.v # uSockets backend with WebSocket/SSE support
|
├── usockets_server.v # uSockets backend with WebSocket/SSE support
|
||||||
├── v.mod # Module definition
|
├── v.mod # Module definition
|
||||||
@@ -1154,6 +1336,7 @@ See the `examples/` directory for more examples:
|
|||||||
- `examples/middleware_demo.v` - Built-in middleware demonstration
|
- `examples/middleware_demo.v` - Built-in middleware demonstration
|
||||||
- `examples/route_grouping/` - Route grouping
|
- `examples/route_grouping/` - Route grouping
|
||||||
- `examples/redirect_demo.v` - Redirect functionality examples
|
- `examples/redirect_demo.v` - Redirect functionality examples
|
||||||
|
- `examples/swagger_demo.v` - Swagger UI and OpenAPI documentation
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -578,6 +578,58 @@ pub fn Hono.new() Hono {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenAPI 文档便捷方法 (Task 9.1)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// doc - 注册 OpenAPI 文档路由
|
||||||
|
// 在指定路径注册一个 GET 路由,返回 OpenAPI 文档的 JSON 格式
|
||||||
|
// 设置 Content-Type: application/json
|
||||||
|
// 使用示例:
|
||||||
|
// spec := OpenAPIBuilder.new()
|
||||||
|
// .openapi('3.0.0')
|
||||||
|
// .title('My API')
|
||||||
|
// .version('1.0.0')
|
||||||
|
// .build()!
|
||||||
|
// app.doc('/doc', spec)
|
||||||
|
pub fn (mut app Hono) doc(path string, spec OpenAPIDocument) {
|
||||||
|
// 预序列化 OpenAPI 文档为 JSON 字符串
|
||||||
|
json_content := spec.to_json_str()
|
||||||
|
|
||||||
|
// 注册 GET 路由返回 JSON
|
||||||
|
app.get(path, fn [json_content] (mut c Context) http.Response {
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: http.new_header(key: .content_type, value: 'application/json; charset=utf-8')
|
||||||
|
body: json_content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// doc_fn - 使用构建器函数注册 OpenAPI 文档路由
|
||||||
|
// 允许延迟构建 OpenAPI 文档,每次请求时调用构建器函数
|
||||||
|
// 使用示例:
|
||||||
|
// app.doc_fn('/doc', fn () OpenAPIDocument {
|
||||||
|
// return OpenAPIBuilder.new()
|
||||||
|
// .openapi('3.0.0')
|
||||||
|
// .title('My API')
|
||||||
|
// .version('1.0.0')
|
||||||
|
// .build() or { panic(err) }
|
||||||
|
// })
|
||||||
|
pub fn (mut app Hono) doc_fn(path string, builder fn () OpenAPIDocument) {
|
||||||
|
app.get(path, fn [builder] (mut c Context) http.Response {
|
||||||
|
// 每次请求时调用构建器函数
|
||||||
|
spec := builder()
|
||||||
|
json_content := spec.to_json_str()
|
||||||
|
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: http.new_header(key: .content_type, value: 'application/json; charset=utf-8')
|
||||||
|
body: json_content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 快速解析 query string(单次遍历,避免 split)- app.v 版本
|
// 快速解析 query string(单次遍历,避免 split)- app.v 版本
|
||||||
@[inline]
|
@[inline]
|
||||||
fn parse_query_string_app(query_str string) map[string]string {
|
fn parse_query_string_app(query_str string) map[string]string {
|
||||||
|
|||||||
@@ -448,21 +448,49 @@ func checkKeepAlive(client *http.Client) bool {
|
|||||||
|
|
||||||
func checkThroughput(client *http.Client) float64 {
|
func checkThroughput(client *http.Client) float64 {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
success := 0
|
concurrency := 1000
|
||||||
requests := 200
|
totalRequests := 1000000
|
||||||
|
requestsPerWorker := totalRequests / concurrency
|
||||||
|
|
||||||
for i := 0; i < requests; i++ {
|
// 创建共享的 Transport,启用连接池复用
|
||||||
resp, err := client.Get(baseURL + "/health")
|
transport := &http.Transport{
|
||||||
|
MaxIdleConns: concurrency,
|
||||||
|
MaxIdleConnsPerHost: concurrency,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan int, concurrency)
|
||||||
|
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
go func() {
|
||||||
|
localSuccess := 0
|
||||||
|
// 每个 goroutine 使用独立 Client,但共享 Transport(连接池)
|
||||||
|
localClient := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
for j := 0; j < requestsPerWorker; j++ {
|
||||||
|
resp, err := localClient.Get(baseURL + "/health")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode == 200 {
|
if resp.StatusCode == 200 {
|
||||||
success++
|
localSuccess++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
results <- localSuccess
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
success += <-results
|
||||||
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start).Seconds()
|
elapsed := time.Since(start).Seconds()
|
||||||
|
fmt.Printf("\n [并发: %d, 总请求: %d, 成功: %d, 耗时: %.2fs]\n ", concurrency, totalRequests, success, elapsed)
|
||||||
return float64(success) / elapsed
|
return float64(success) / elapsed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// Swagger UI Demo for v-hono
|
||||||
|
// This example demonstrates how to add interactive API documentation to your v-hono application
|
||||||
|
// Run with: v -enable-globals run examples/swagger_demo.v (from project root)
|
||||||
|
// Or: cd examples && v -enable-globals run swagger_demo.v
|
||||||
|
// Then visit: http://127.0.0.1:3000/ui for Swagger UI
|
||||||
|
// http://127.0.0.1:3000/doc for OpenAPI JSON
|
||||||
|
module main
|
||||||
|
|
||||||
|
import net.http
|
||||||
|
import x.json2
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
// Pet 结构体 - 用于演示 API 数据
|
||||||
|
struct Pet {
|
||||||
|
id int
|
||||||
|
name string
|
||||||
|
tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (p Pet) as_map() map[string]string {
|
||||||
|
return {
|
||||||
|
"name": p.name,
|
||||||
|
"tag": p.tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ErrorResponse - 错误响应结构体
|
||||||
|
struct ErrorResponse {
|
||||||
|
error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用全局变量实现共享状态
|
||||||
|
// 注意:需要使用 -enable-globals 编译选项
|
||||||
|
__global (
|
||||||
|
g_pets = []Pet{}
|
||||||
|
g_next_id = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Basic Usage - Using OpenAPI Builder API
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Build OpenAPI specification using the fluent builder API
|
||||||
|
// Note: In V, we need to create a mutable builder first, then chain methods
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Pet Store API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
builder.description('A sample Pet Store API demonstrating Swagger UI integration with v-hono')
|
||||||
|
builder.server('http://127.0.0.1:3000', 'Development server')
|
||||||
|
builder.tag('pets', 'Pet management operations')
|
||||||
|
builder.tag('users', 'User management operations')
|
||||||
|
|
||||||
|
// Define GET /pets endpoint
|
||||||
|
mut pets_path := builder.path('/pets')
|
||||||
|
pets_path.get(hono.OpenAPIOperation{
|
||||||
|
summary: 'List all pets'
|
||||||
|
description: 'Returns a list of all pets in the store'
|
||||||
|
operation_id: 'listPets'
|
||||||
|
tags: ['pets']
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'limit'
|
||||||
|
in_location: 'query'
|
||||||
|
description: 'Maximum number of pets to return'
|
||||||
|
required: false
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int32'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'A list of pets'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'array'
|
||||||
|
items: &hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/Pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pets_path.post(hono.OpenAPIOperation{
|
||||||
|
summary: 'Create a pet'
|
||||||
|
description: 'Creates a new pet in the store'
|
||||||
|
operation_id: 'createPet'
|
||||||
|
tags: ['pets']
|
||||||
|
request_body: hono.OpenAPIRequestBody{
|
||||||
|
description: 'Pet to add to the store'
|
||||||
|
required: true
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/NewPet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
'201': hono.OpenAPIResponse{
|
||||||
|
description: 'Pet created successfully'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/Pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pets_path.done()
|
||||||
|
|
||||||
|
// Define GET/DELETE /pets/{id} endpoint
|
||||||
|
mut pet_by_id_path := builder.path('/pets/{id}')
|
||||||
|
pet_by_id_path.get(hono.OpenAPIOperation{
|
||||||
|
summary: 'Get a pet by ID'
|
||||||
|
description: 'Returns a single pet by its ID'
|
||||||
|
operation_id: 'getPetById'
|
||||||
|
tags: ['pets']
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'id'
|
||||||
|
in_location: 'path'
|
||||||
|
description: 'ID of the pet to retrieve'
|
||||||
|
required: true
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int64'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Pet found'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/Pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'404': hono.OpenAPIResponse{
|
||||||
|
description: 'Pet not found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pet_by_id_path.delete(hono.OpenAPIOperation{
|
||||||
|
summary: 'Delete a pet'
|
||||||
|
description: 'Deletes a pet by its ID'
|
||||||
|
operation_id: 'deletePet'
|
||||||
|
tags: ['pets']
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'id'
|
||||||
|
in_location: 'path'
|
||||||
|
description: 'ID of the pet to delete'
|
||||||
|
required: true
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int64'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'204': hono.OpenAPIResponse{
|
||||||
|
description: 'Pet deleted successfully'
|
||||||
|
}
|
||||||
|
'404': hono.OpenAPIResponse{
|
||||||
|
description: 'Pet not found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pet_by_id_path.done()
|
||||||
|
|
||||||
|
// Add reusable schemas to components
|
||||||
|
builder.add_schema('Pet', hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
required: ['id', 'name']
|
||||||
|
properties: {
|
||||||
|
'id': hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int64'
|
||||||
|
description: 'Unique identifier for the pet'
|
||||||
|
}
|
||||||
|
'name': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
description: 'Name of the pet'
|
||||||
|
}
|
||||||
|
'tag': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
description: 'Tag for categorizing the pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
builder.add_schema('NewPet', hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
required: ['name']
|
||||||
|
properties: {
|
||||||
|
'name': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
description: 'Name of the pet'
|
||||||
|
}
|
||||||
|
'tag': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
description: 'Tag for categorizing the pet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
spec := builder.build() or {
|
||||||
|
eprintln('Failed to build OpenAPI spec: ${err}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the OpenAPI JSON endpoint
|
||||||
|
app.doc('/doc', spec)
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Swagger UI with Default Options
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Serve Swagger UI at /ui with default options
|
||||||
|
app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Pet Store API Documentation'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Swagger UI with Custom Options
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Serve Swagger UI at /docs with custom options
|
||||||
|
app.get('/docs', hono.swagger_ui(hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Pet Store API - Custom Theme'
|
||||||
|
deep_linking: true
|
||||||
|
display_request_duration: true
|
||||||
|
default_models_expand_depth: 2
|
||||||
|
doc_expansion: 'full' // 'list', 'full', or 'none'
|
||||||
|
filter: true
|
||||||
|
show_extensions: true
|
||||||
|
show_common_extensions: true
|
||||||
|
try_it_out_enabled: true
|
||||||
|
// Custom CSS to change the header color
|
||||||
|
custom_css: '.swagger-ui .topbar { background-color: #2c3e50; }'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. Actual API Endpoints (for testing with Swagger UI)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// 初始化全局宠物数据
|
||||||
|
// 使用全局变量可以在多个 handler 之间共享和修改数据
|
||||||
|
g_pets = [
|
||||||
|
Pet{id: 1, name: 'Fluffy', tag: 'cat'},
|
||||||
|
Pet{id: 2, name: 'Buddy', tag: 'dog'},
|
||||||
|
Pet{id: 3, name: 'Goldie', tag: 'fish'},
|
||||||
|
]
|
||||||
|
|
||||||
|
// GET /pets - List all pets
|
||||||
|
app.get('/pets', fn (mut c hono.Context) http.Response {
|
||||||
|
return c.json(json2.encode(g_pets))
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /pets - Create a new pet
|
||||||
|
app.post('/pets', fn (mut c hono.Context) http.Response {
|
||||||
|
println(c.body)
|
||||||
|
pet_dic := json2.decode[Pet](c.body) or {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
pet := Pet{id: g_next_id, name: pet_dic.name, tag: pet_dic.tag}
|
||||||
|
g_pets << pet
|
||||||
|
g_next_id++
|
||||||
|
c.status(201)
|
||||||
|
return c.json(json2.encode(pet))
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /pets/:id - Get a pet by ID
|
||||||
|
app.get('/pets/:id', fn (mut c hono.Context) http.Response {
|
||||||
|
pet_id := c.params['id'] or { '' }
|
||||||
|
for pet in g_pets {
|
||||||
|
if pet.id == pet_id.int() {
|
||||||
|
return c.json(json2.encode(pet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.status(404)
|
||||||
|
return c.json(json2.encode(ErrorResponse{error: 'Pet not found'}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /pets/:id - Delete a pet
|
||||||
|
app.delete('/pets/:id', fn (mut c hono.Context) http.Response {
|
||||||
|
pet_id := c.params['id'] or { '' }
|
||||||
|
for i, pet in g_pets {
|
||||||
|
if pet.id == pet_id.int() {
|
||||||
|
g_pets.delete(i)
|
||||||
|
return http.Response{
|
||||||
|
status_code: 204
|
||||||
|
header: http.new_header(key: .content_type, value: 'application/json')
|
||||||
|
body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.status(404)
|
||||||
|
return c.json(json2.encode(ErrorResponse{error: 'Pet not found'}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Start Server
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
println('=== Swagger UI Demo ===')
|
||||||
|
println('Server starting on http://127.0.0.1:3000')
|
||||||
|
println('')
|
||||||
|
println('Available endpoints:')
|
||||||
|
println(' - Swagger UI (default): http://127.0.0.1:3000/ui')
|
||||||
|
println(' - Swagger UI (custom): http://127.0.0.1:3000/docs')
|
||||||
|
println(' - OpenAPI JSON: http://127.0.0.1:3000/doc')
|
||||||
|
println('')
|
||||||
|
println('API endpoints:')
|
||||||
|
println(' - GET /pets - List all pets')
|
||||||
|
println(' - POST /pets - Create a new pet')
|
||||||
|
println(' - GET /pets/:id - Get a pet by ID')
|
||||||
|
println(' - DELETE /pets/:id - Delete a pet')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
app.listen_usockets(3000)
|
||||||
|
}
|
||||||
+109
-1
@@ -11,7 +11,7 @@ import rand
|
|||||||
// 中间件导出说明
|
// 中间件导出说明
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
//
|
//
|
||||||
// 本框架提供以下 8 个内置中间件:
|
// 本框架提供以下 9 个内置中间件:
|
||||||
//
|
//
|
||||||
// 1. CORS 中间件 (cors.v)
|
// 1. CORS 中间件 (cors.v)
|
||||||
// - cors(options ...CorsOptions) ContextMiddleware
|
// - cors(options ...CorsOptions) ContextMiddleware
|
||||||
@@ -82,6 +82,57 @@ import rand
|
|||||||
// - encode_ws_frame(opcode u8, payload []u8, masked bool) []u8: 编码 WebSocket 帧
|
// - encode_ws_frame(opcode u8, payload []u8, masked bool) []u8: 编码 WebSocket 帧
|
||||||
// - decode_ws_frame(data []u8) !WSFrame: 解码 WebSocket 帧
|
// - decode_ws_frame(data []u8) !WSFrame: 解码 WebSocket 帧
|
||||||
//
|
//
|
||||||
|
// 9. Swagger UI 中间件 (swagger.v)
|
||||||
|
// - swagger_ui(options ...SwaggerUIOptions) fn (mut Context) http.Response
|
||||||
|
// - swagger_ui_handler(options ...SwaggerUIOptions) fn (mut Context) http.Response
|
||||||
|
// - 用于提供交互式 API 文档界面
|
||||||
|
//
|
||||||
|
// Types:
|
||||||
|
// - SwaggerUIOptions: 配置选项
|
||||||
|
// - url: OpenAPI 文档 URL (默认: '/doc')
|
||||||
|
// - title: 页面标题 (默认: 'API Documentation')
|
||||||
|
// - deep_linking: 启用深度链接 (默认: true)
|
||||||
|
// - display_request_duration: 显示请求耗时 (默认: true)
|
||||||
|
// - default_models_expand_depth: 模型展开深度 (默认: 1)
|
||||||
|
// - doc_expansion: 文档展开方式 ('list', 'full', 'none')
|
||||||
|
// - filter: 启用过滤
|
||||||
|
// - show_extensions: 显示扩展
|
||||||
|
// - show_common_extensions: 显示常用扩展 (默认: true)
|
||||||
|
// - try_it_out_enabled: 启用 Try it out (默认: true)
|
||||||
|
// - custom_css: 自定义 CSS
|
||||||
|
// - custom_js: 自定义 JavaScript
|
||||||
|
// - custom_css_url: 自定义 CSS URL
|
||||||
|
// - custom_js_url: 自定义 JavaScript URL
|
||||||
|
//
|
||||||
|
// OpenAPI 文档相关 (openapi.v):
|
||||||
|
// - OpenAPIDocument: OpenAPI 文档主结构
|
||||||
|
// - OpenAPIBuilder: 流式 API 构建器
|
||||||
|
// - app.doc(path, spec): 注册 OpenAPI 文档路由
|
||||||
|
// - app.doc_fn(path, builder): 使用构建器函数注册文档路由
|
||||||
|
// - app.get_routes(): 获取应用的所有路由信息
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
// // 1. 创建 OpenAPI 文档
|
||||||
|
// spec := hono.OpenAPIBuilder.new()
|
||||||
|
// .openapi('3.0.0')
|
||||||
|
// .title('My API')
|
||||||
|
// .version('1.0.0')
|
||||||
|
// .description('API description')
|
||||||
|
// .server('https://api.example.com', 'Production')
|
||||||
|
// .path('/users')
|
||||||
|
// .get(hono.OpenAPIOperation{
|
||||||
|
// summary: 'Get all users'
|
||||||
|
// responses: { '200': hono.OpenAPIResponse{ description: 'Success' } }
|
||||||
|
// })
|
||||||
|
// .done()
|
||||||
|
// .build()!
|
||||||
|
//
|
||||||
|
// // 2. 注册 OpenAPI 文档路由
|
||||||
|
// app.doc('/doc', spec)
|
||||||
|
//
|
||||||
|
// // 3. 注册 Swagger UI 路由
|
||||||
|
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
|
||||||
|
//
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -294,6 +345,63 @@ pub fn timing() ContextMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Swagger UI 中间件导出
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// Swagger UI 中间件提供交互式 API 文档界面,支持 OpenAPI 3.0/3.1 规范。
|
||||||
|
// 主要功能通过 swagger.v 和 openapi.v 文件导出,包括:
|
||||||
|
//
|
||||||
|
// 核心函数:
|
||||||
|
// - swagger_ui(options...) - 创建 Swagger UI 处理器
|
||||||
|
// - swagger_ui_handler(options...) - swagger_ui 的别名
|
||||||
|
// - app.doc(path, spec) - 注册 OpenAPI 文档路由
|
||||||
|
// - app.doc_fn(path, builder) - 使用构建器函数注册文档路由
|
||||||
|
//
|
||||||
|
// 类型定义:
|
||||||
|
// - SwaggerUIOptions - Swagger UI 配置选项
|
||||||
|
// - OpenAPIDocument - OpenAPI 文档主结构
|
||||||
|
// - OpenAPIBuilder - 流式 API 构建器
|
||||||
|
// - OpenAPIInfo, OpenAPIServer, OpenAPIPathItem, OpenAPIOperation 等
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
// // 创建 OpenAPI 文档
|
||||||
|
// spec := hono.OpenAPIBuilder.new()
|
||||||
|
// .openapi('3.0.0')
|
||||||
|
// .title('My API')
|
||||||
|
// .version('1.0.0')
|
||||||
|
// .build()!
|
||||||
|
//
|
||||||
|
// // 注册文档和 UI 路由
|
||||||
|
// app.doc('/doc', spec)
|
||||||
|
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
|
||||||
|
//
|
||||||
|
// 带自定义选项:
|
||||||
|
// app.get('/swagger', hono.swagger_ui(hono.SwaggerUIOptions{
|
||||||
|
// url: '/api/doc'
|
||||||
|
// title: 'My API Documentation'
|
||||||
|
// deep_linking: true
|
||||||
|
// display_request_duration: true
|
||||||
|
// doc_expansion: 'list'
|
||||||
|
// try_it_out_enabled: true
|
||||||
|
// }))
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// swagger - Swagger UI 处理器的简短别名
|
||||||
|
// 使用示例:
|
||||||
|
// app.get('/docs', hono.swagger(hono.SwaggerUIOptions{ url: '/doc' }))
|
||||||
|
pub fn swagger(options ...SwaggerUIOptions) fn (mut Context) http.Response {
|
||||||
|
return swagger_ui(...options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openapi_ui - Swagger UI 处理器的另一个别名
|
||||||
|
// 使用示例:
|
||||||
|
// app.get('/api-docs', hono.openapi_ui(hono.SwaggerUIOptions{ url: '/openapi.json' }))
|
||||||
|
pub fn openapi_ui(options ...SwaggerUIOptions) fn (mut Context) http.Response {
|
||||||
|
return swagger_ui(...options)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WebSocket Helper 导出
|
// WebSocket Helper 导出
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// swagger.v - Swagger UI 中间件
|
||||||
|
// 本模块提供 Swagger UI 文档界面功能,类似于 Hono.js 的 @hono/swagger-ui 中间件
|
||||||
|
// 支持 OpenAPI 3.0/3.1 规范
|
||||||
|
module hono
|
||||||
|
|
||||||
|
import net.http
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Swagger UI 配置选项 (Task 7.1)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// SwaggerUIOptions - Swagger UI 配置选项
|
||||||
|
// 使用示例:
|
||||||
|
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
|
||||||
|
pub struct SwaggerUIOptions {
|
||||||
|
pub mut:
|
||||||
|
url string = '/doc' // OpenAPI 文档 URL
|
||||||
|
title string = 'API Documentation' // 页面标题
|
||||||
|
deep_linking bool = true // 启用深度链接
|
||||||
|
display_request_duration bool = true // 显示请求耗时
|
||||||
|
default_models_expand_depth int = 1 // 模型展开深度
|
||||||
|
doc_expansion string = 'list' // 文档展开方式: 'list', 'full', 'none'
|
||||||
|
filter bool // 启用过滤
|
||||||
|
show_extensions bool // 显示扩展
|
||||||
|
show_common_extensions bool = true // 显示常用扩展
|
||||||
|
try_it_out_enabled bool = true // 启用 Try it out
|
||||||
|
custom_css string // 自定义 CSS
|
||||||
|
custom_js string // 自定义 JavaScript
|
||||||
|
custom_css_url string // 自定义 CSS URL
|
||||||
|
custom_js_url string // 自定义 JavaScript URL
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Swagger UI HTML 生成 (Task 7.2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// generate_swagger_html - 生成 Swagger UI HTML 页面
|
||||||
|
// 使用 CDN 链接加载 Swagger UI 资源
|
||||||
|
// 应用配置选项到 SwaggerUIBundle 配置
|
||||||
|
fn generate_swagger_html(options SwaggerUIOptions) string {
|
||||||
|
// 构建自定义 CSS URL 标签
|
||||||
|
custom_css_url_tag := if options.custom_css_url.len > 0 {
|
||||||
|
'<link rel="stylesheet" href="${options.custom_css_url}">'
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建自定义 JS URL 标签
|
||||||
|
custom_js_url_tag := if options.custom_js_url.len > 0 {
|
||||||
|
'<script src="${options.custom_js_url}"></script>'
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建自定义 CSS 内联样式
|
||||||
|
custom_css_inline := if options.custom_css.len > 0 {
|
||||||
|
options.custom_css
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建自定义 JS 内联脚本
|
||||||
|
custom_js_inline := if options.custom_js.len > 0 {
|
||||||
|
options.custom_js
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换布尔值为 JavaScript 字符串
|
||||||
|
deep_linking_str := if options.deep_linking { 'true' } else { 'false' }
|
||||||
|
display_request_duration_str := if options.display_request_duration { 'true' } else { 'false' }
|
||||||
|
filter_str := if options.filter { 'true' } else { 'false' }
|
||||||
|
show_extensions_str := if options.show_extensions { 'true' } else { 'false' }
|
||||||
|
show_common_extensions_str := if options.show_common_extensions { 'true' } else { 'false' }
|
||||||
|
try_it_out_enabled_str := if options.try_it_out_enabled { 'true' } else { 'false' }
|
||||||
|
|
||||||
|
return '<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${options.title}</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
${custom_css_url_tag}
|
||||||
|
<style>
|
||||||
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
|
*, *:before, *:after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; background: #fafafa; }
|
||||||
|
${custom_css_inline}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||||
|
${custom_js_url_tag}
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: "${options.url}",
|
||||||
|
dom_id: \'#swagger-ui\',
|
||||||
|
deepLinking: ${deep_linking_str},
|
||||||
|
displayRequestDuration: ${display_request_duration_str},
|
||||||
|
defaultModelsExpandDepth: ${options.default_models_expand_depth},
|
||||||
|
docExpansion: "${options.doc_expansion}",
|
||||||
|
filter: ${filter_str},
|
||||||
|
showExtensions: ${show_extensions_str},
|
||||||
|
showCommonExtensions: ${show_common_extensions_str},
|
||||||
|
tryItOutEnabled: ${try_it_out_enabled_str},
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout"
|
||||||
|
});
|
||||||
|
window.ui = ui;
|
||||||
|
};
|
||||||
|
${custom_js_inline}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Swagger UI 中间件函数 (Task 7.3)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// swagger_ui - 创建 Swagger UI 处理器
|
||||||
|
// 返回处理器函数,设置正确的 Content-Type,返回生成的 HTML
|
||||||
|
// 使用示例:
|
||||||
|
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
|
||||||
|
// app.get('/docs', hono.swagger_ui()) // 使用默认选项
|
||||||
|
pub fn swagger_ui(options ...SwaggerUIOptions) fn (mut Context) http.Response {
|
||||||
|
// 获取选项,如果没有提供则使用默认值
|
||||||
|
opts := if options.len > 0 {
|
||||||
|
options[0]
|
||||||
|
} else {
|
||||||
|
SwaggerUIOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预生成 HTML 内容
|
||||||
|
html_content := generate_swagger_html(opts)
|
||||||
|
|
||||||
|
return fn [html_content] (mut c Context) http.Response {
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: http.new_header(key: .content_type, value: 'text/html; charset=utf-8')
|
||||||
|
body: html_content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger_ui_handler - swagger_ui 的别名函数
|
||||||
|
// 使用示例:
|
||||||
|
// app.get('/swagger', hono.swagger_ui_handler(hono.SwaggerUIOptions{ url: '/api/doc' }))
|
||||||
|
pub fn swagger_ui_handler(options ...SwaggerUIOptions) fn (mut Context) http.Response {
|
||||||
|
return swagger_ui(...options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// test_openapi_builder_multi_ops_property.v - Property-Based Test for Builder Multiple Operations
|
||||||
|
// **Feature: swagger-ui, Property 7: OpenAPI Builder Supports Multiple Operations Per Path**
|
||||||
|
// **Validates: Requirements 6.2, 6.3, 6.4**
|
||||||
|
//
|
||||||
|
// Property: For any path, adding multiple different HTTP method operations using the builder
|
||||||
|
// SHALL result in all operations being present in the final document.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random path generator
|
||||||
|
fn random_path() string {
|
||||||
|
segments := rand.int_in_range(1, 4) or { 1 }
|
||||||
|
mut path := ''
|
||||||
|
for _ in 0 .. segments {
|
||||||
|
path += '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// All supported HTTP methods
|
||||||
|
const http_methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options']
|
||||||
|
|
||||||
|
// Generate a random operation with a unique identifier
|
||||||
|
fn generate_operation(method string, id string) hono.OpenAPIOperation {
|
||||||
|
return hono.OpenAPIOperation{
|
||||||
|
summary: 'Test ${method} operation - ${id}'
|
||||||
|
description: 'Description for ${method} - ${id}'
|
||||||
|
operation_id: '${method}_${id}'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success response for ${method}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an operation is set (has responses)
|
||||||
|
fn is_operation_set(op hono.OpenAPIOperation) bool {
|
||||||
|
return op.responses.len > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get operation from path item by method name
|
||||||
|
fn get_operation_by_method(path_item hono.OpenAPIPathItem, method string) hono.OpenAPIOperation {
|
||||||
|
return match method {
|
||||||
|
'get' { path_item.get }
|
||||||
|
'post' { path_item.post }
|
||||||
|
'put' { path_item.put }
|
||||||
|
'delete' { path_item.delete }
|
||||||
|
'patch' { path_item.patch }
|
||||||
|
'head' { path_item.head }
|
||||||
|
'options' { path_item.options }
|
||||||
|
else { hono.OpenAPIOperation{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Randomly select a subset of HTTP methods
|
||||||
|
fn random_method_subset() []string {
|
||||||
|
// Select between 2 and 7 methods
|
||||||
|
num_methods := rand.int_in_range(2, http_methods.len + 1) or { 2 }
|
||||||
|
|
||||||
|
mut selected := []string{}
|
||||||
|
mut available := http_methods.clone()
|
||||||
|
|
||||||
|
for _ in 0 .. num_methods {
|
||||||
|
if available.len == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
idx := rand.int_in_range(0, available.len) or { 0 }
|
||||||
|
selected << available[idx]
|
||||||
|
available.delete(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that multiple operations can be added to the same path
|
||||||
|
fn test_multiple_operations_on_path() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 7: OpenAPI Builder Supports Multiple Operations Per Path')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
path := random_path()
|
||||||
|
methods := random_method_subset()
|
||||||
|
unique_id := random_string(5, 10)
|
||||||
|
|
||||||
|
// Create operations for selected methods
|
||||||
|
mut operations := map[string]hono.OpenAPIOperation{}
|
||||||
|
for method in methods {
|
||||||
|
operations[method] = generate_operation(method, unique_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build document using the builder
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
mut path_builder := builder.path(path)
|
||||||
|
|
||||||
|
// Add operations for each selected method
|
||||||
|
for method in methods {
|
||||||
|
op := operations[method] or { continue }
|
||||||
|
match method {
|
||||||
|
'get' { path_builder.get(op) }
|
||||||
|
'post' { path_builder.post(op) }
|
||||||
|
'put' { path_builder.put(op) }
|
||||||
|
'delete' { path_builder.delete(op) }
|
||||||
|
'patch' { path_builder.patch(op) }
|
||||||
|
'head' { path_builder.head(op) }
|
||||||
|
'options' { path_builder.options(op) }
|
||||||
|
else {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path_builder.done()
|
||||||
|
|
||||||
|
// Build the document
|
||||||
|
doc := builder.build() or {
|
||||||
|
stats.record_fail('Iteration ${i}: Build failed - ${err}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path exists
|
||||||
|
if path !in doc.paths {
|
||||||
|
stats.record_fail('Iteration ${i}: Path ${path} not found in document')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path_item := doc.paths[path] or {
|
||||||
|
stats.record_fail('Iteration ${i}: Could not get path item')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all selected methods are present
|
||||||
|
mut all_preserved := true
|
||||||
|
mut missing_methods := []string{}
|
||||||
|
|
||||||
|
for method in methods {
|
||||||
|
stored_op := get_operation_by_method(path_item, method)
|
||||||
|
expected_op := operations[method] or { continue }
|
||||||
|
|
||||||
|
if !is_operation_set(stored_op) {
|
||||||
|
all_preserved = false
|
||||||
|
missing_methods << method
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored_op.summary != expected_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
missing_methods << '${method}(summary mismatch)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_preserved {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Methods not preserved: ${missing_methods.join(", ")}. Selected: ${methods.join(", ")}')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that operations on different paths don't interfere
|
||||||
|
fn test_multiple_paths_with_operations() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('\nRunning additional test: Multiple paths with different operations')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Generate 2-3 different paths with fixed operations
|
||||||
|
path1 := random_path()
|
||||||
|
path2 := random_path()
|
||||||
|
unique_id := random_string(5, 10)
|
||||||
|
|
||||||
|
// Create operations for each path
|
||||||
|
get_op1 := generate_operation('get', '${path1}_${unique_id}')
|
||||||
|
post_op1 := generate_operation('post', '${path1}_${unique_id}')
|
||||||
|
get_op2 := generate_operation('get', '${path2}_${unique_id}')
|
||||||
|
put_op2 := generate_operation('put', '${path2}_${unique_id}')
|
||||||
|
|
||||||
|
// Build document
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
// Add path1 with GET and POST
|
||||||
|
mut path_builder1 := builder.path(path1)
|
||||||
|
path_builder1.get(get_op1)
|
||||||
|
path_builder1.post(post_op1)
|
||||||
|
path_builder1.done()
|
||||||
|
|
||||||
|
// Add path2 with GET and PUT
|
||||||
|
mut path_builder2 := builder.path(path2)
|
||||||
|
path_builder2.get(get_op2)
|
||||||
|
path_builder2.put(put_op2)
|
||||||
|
path_builder2.done()
|
||||||
|
|
||||||
|
doc := builder.build() or {
|
||||||
|
stats.record_fail('Iteration ${i}: Build failed - ${err}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all paths and their operations
|
||||||
|
mut all_correct := true
|
||||||
|
mut errors := []string{}
|
||||||
|
|
||||||
|
// Check path1
|
||||||
|
if path1 !in doc.paths {
|
||||||
|
all_correct = false
|
||||||
|
errors << 'Path ${path1} not found'
|
||||||
|
} else {
|
||||||
|
path_item1 := doc.paths[path1] or {
|
||||||
|
all_correct = false
|
||||||
|
errors << 'Could not get path item for ${path1}'
|
||||||
|
hono.OpenAPIPathItem{}
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item1.get) || path_item1.get.summary != get_op1.summary {
|
||||||
|
all_correct = false
|
||||||
|
errors << '${path1}:get'
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item1.post) || path_item1.post.summary != post_op1.summary {
|
||||||
|
all_correct = false
|
||||||
|
errors << '${path1}:post'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check path2
|
||||||
|
if path2 !in doc.paths {
|
||||||
|
all_correct = false
|
||||||
|
errors << 'Path ${path2} not found'
|
||||||
|
} else {
|
||||||
|
path_item2 := doc.paths[path2] or {
|
||||||
|
all_correct = false
|
||||||
|
errors << 'Could not get path item for ${path2}'
|
||||||
|
hono.OpenAPIPathItem{}
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item2.get) || path_item2.get.summary != get_op2.summary {
|
||||||
|
all_correct = false
|
||||||
|
errors << '${path2}:get'
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item2.put) || path_item2.put.summary != put_op2.summary {
|
||||||
|
all_correct = false
|
||||||
|
errors << '${path2}:put'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_correct {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Errors: ${errors.join(", ")}')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: OpenAPI Builder Multiple Operations Per Path')
|
||||||
|
println('=====================================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
// Run main property test
|
||||||
|
stats1 := test_multiple_operations_on_path()
|
||||||
|
stats1.print_summary()
|
||||||
|
|
||||||
|
// Run additional test
|
||||||
|
stats2 := test_multiple_paths_with_operations()
|
||||||
|
stats2.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats1.failed_tests > 0 || stats2.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// test_openapi_http_methods_property.v - Property-Based Test for HTTP Methods Support
|
||||||
|
// **Feature: swagger-ui, Property 4: HTTP Methods Support in Path Items**
|
||||||
|
// **Validates: Requirements 2.3**
|
||||||
|
//
|
||||||
|
// Property: For any HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS),
|
||||||
|
// adding an operation with that method to a path item SHALL succeed and be preserved in the document.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random path generator
|
||||||
|
fn random_path() string {
|
||||||
|
segments := rand.int_in_range(1, 4) or { 1 }
|
||||||
|
mut path := ''
|
||||||
|
for _ in 0 .. segments {
|
||||||
|
path += '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// All supported HTTP methods
|
||||||
|
const http_methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options']
|
||||||
|
|
||||||
|
// Generate a random operation with a unique summary for identification
|
||||||
|
fn generate_operation(method string) hono.OpenAPIOperation {
|
||||||
|
return hono.OpenAPIOperation{
|
||||||
|
summary: 'Test ${method} operation - ${random_string(5, 10)}'
|
||||||
|
description: 'Description for ${method}'
|
||||||
|
operation_id: '${method}_${random_string(5, 10)}'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success response for ${method}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an operation is set (has responses)
|
||||||
|
fn is_operation_set(op hono.OpenAPIOperation) bool {
|
||||||
|
return op.responses.len > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get operation from path item by method name
|
||||||
|
fn get_operation_by_method(path_item hono.OpenAPIPathItem, method string) hono.OpenAPIOperation {
|
||||||
|
return match method {
|
||||||
|
'get' { path_item.get }
|
||||||
|
'post' { path_item.post }
|
||||||
|
'put' { path_item.put }
|
||||||
|
'delete' { path_item.delete }
|
||||||
|
'patch' { path_item.patch }
|
||||||
|
'head' { path_item.head }
|
||||||
|
'options' { path_item.options }
|
||||||
|
else { hono.OpenAPIOperation{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that each HTTP method can be added via the builder and is preserved
|
||||||
|
fn test_http_method_via_builder(method string) bool {
|
||||||
|
path := random_path()
|
||||||
|
op := generate_operation(method)
|
||||||
|
|
||||||
|
// Build document using the builder - step by step
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
// Add operation using the path builder
|
||||||
|
mut path_builder := builder.path(path)
|
||||||
|
|
||||||
|
match method {
|
||||||
|
'get' { path_builder.get(op) }
|
||||||
|
'post' { path_builder.post(op) }
|
||||||
|
'put' { path_builder.put(op) }
|
||||||
|
'delete' { path_builder.delete(op) }
|
||||||
|
'patch' { path_builder.patch(op) }
|
||||||
|
'head' { path_builder.head(op) }
|
||||||
|
'options' { path_builder.options(op) }
|
||||||
|
else {}
|
||||||
|
}
|
||||||
|
|
||||||
|
path_builder.done()
|
||||||
|
|
||||||
|
// Build the document
|
||||||
|
doc := builder.build() or { return false }
|
||||||
|
|
||||||
|
// Verify the path exists
|
||||||
|
if path !in doc.paths {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the operation is preserved
|
||||||
|
path_item := doc.paths[path] or { return false }
|
||||||
|
stored_op := get_operation_by_method(path_item, method)
|
||||||
|
|
||||||
|
// Check operation is set and has correct summary
|
||||||
|
if !is_operation_set(stored_op) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored_op.summary != op.summary {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored_op.operation_id != op.operation_id {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test: All HTTP methods are supported
|
||||||
|
fn test_http_methods_property() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 4: HTTP Methods Support in Path Items')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Pick a random HTTP method
|
||||||
|
method_idx := rand.int_in_range(0, http_methods.len) or { 0 }
|
||||||
|
method := http_methods[method_idx]
|
||||||
|
|
||||||
|
if test_http_method_via_builder(method) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Method ${method} was not preserved in document')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Additional test: All methods can be added to the same path
|
||||||
|
fn test_all_methods_on_same_path() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('\nRunning additional test: All HTTP methods on same path')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
path := random_path()
|
||||||
|
|
||||||
|
// Create operations for all methods
|
||||||
|
get_op := generate_operation('get')
|
||||||
|
post_op := generate_operation('post')
|
||||||
|
put_op := generate_operation('put')
|
||||||
|
delete_op := generate_operation('delete')
|
||||||
|
patch_op := generate_operation('patch')
|
||||||
|
head_op := generate_operation('head')
|
||||||
|
options_op := generate_operation('options')
|
||||||
|
|
||||||
|
// Build document with all methods on the same path
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
mut path_builder := builder.path(path)
|
||||||
|
path_builder.get(get_op)
|
||||||
|
path_builder.post(post_op)
|
||||||
|
path_builder.put(put_op)
|
||||||
|
path_builder.delete(delete_op)
|
||||||
|
path_builder.patch(patch_op)
|
||||||
|
path_builder.head(head_op)
|
||||||
|
path_builder.options(options_op)
|
||||||
|
path_builder.done()
|
||||||
|
|
||||||
|
doc := builder.build() or {
|
||||||
|
stats.record_fail('Iteration ${i}: Build failed - ${err}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all methods are preserved
|
||||||
|
path_item := doc.paths[path] or {
|
||||||
|
stats.record_fail('Iteration ${i}: Path not found')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mut all_preserved := true
|
||||||
|
|
||||||
|
// Check each method
|
||||||
|
if !is_operation_set(path_item.get) || path_item.get.summary != get_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.post) || path_item.post.summary != post_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.put) || path_item.put.summary != put_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.delete) || path_item.delete.summary != delete_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.patch) || path_item.patch.summary != patch_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.head) || path_item.head.summary != head_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
if !is_operation_set(path_item.options) || path_item.options.summary != options_op.summary {
|
||||||
|
all_preserved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_preserved {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Not all methods preserved on path ${path}')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: HTTP Methods Support in Path Items')
|
||||||
|
println('============================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
// Run main property test
|
||||||
|
stats1 := test_http_methods_property()
|
||||||
|
stats1.print_summary()
|
||||||
|
|
||||||
|
// Run additional test
|
||||||
|
stats2 := test_all_methods_on_same_path()
|
||||||
|
stats2.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats1.failed_tests > 0 || stats2.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// test_openapi_invalid_path_property.v - Property-Based Test for Invalid Path Error Identification
|
||||||
|
// **Feature: swagger-ui, Property 9: Invalid Path Error Identification**
|
||||||
|
// **Validates: Requirements 8.3**
|
||||||
|
//
|
||||||
|
// Property: For any OpenAPI document with an invalid path definition,
|
||||||
|
// the validation error message SHALL identify the problematic path.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 5 {
|
||||||
|
println('... and ${stats.failed_examples.len - 5} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random OpenAPI version
|
||||||
|
fn random_openapi_version() string {
|
||||||
|
versions := ['3.0.0', '3.0.1', '3.0.2', '3.0.3', '3.1.0']
|
||||||
|
return versions[rand.int_in_range(0, versions.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a valid path item
|
||||||
|
fn generate_valid_path_item() hono.OpenAPIPathItem {
|
||||||
|
return hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: random_string(5, 20)
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{description: 'Success'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a document with a specific invalid path
|
||||||
|
fn generate_doc_with_invalid_path(invalid_path string) hono.OpenAPIDocument {
|
||||||
|
return hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
invalid_path: generate_valid_path_item()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Path not starting with '/' should return error mentioning the path
|
||||||
|
fn test_path_not_starting_with_slash() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 1: Path not starting with slash validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate invalid path (not starting with /)
|
||||||
|
invalid_path := random_string(3, 15) // e.g., "users" instead of "/users"
|
||||||
|
|
||||||
|
doc := generate_doc_with_invalid_path(invalid_path)
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message contains the invalid path
|
||||||
|
if err.msg().contains(invalid_path) {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't contain invalid path '${invalid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail("Validation passed for invalid path '${invalid_path}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Path with unmatched '{' should return error mentioning the path
|
||||||
|
fn test_path_with_unmatched_open_brace() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 2: Path with unmatched open brace validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate invalid path with unmatched '{'
|
||||||
|
param_name := random_string(3, 10)
|
||||||
|
invalid_path := '/${random_string(3, 10)}/{${param_name}' // e.g., "/users/{id" (missing closing brace)
|
||||||
|
|
||||||
|
doc := generate_doc_with_invalid_path(invalid_path)
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message contains the invalid path
|
||||||
|
if err.msg().contains(invalid_path) {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't contain invalid path '${invalid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail("Validation passed for invalid path '${invalid_path}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Path with unmatched '}' should return error mentioning the path
|
||||||
|
fn test_path_with_unmatched_close_brace() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 3: Path with unmatched close brace validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate invalid path with unmatched '}'
|
||||||
|
param_name := random_string(3, 10)
|
||||||
|
invalid_path := '/${random_string(3, 10)}/${param_name}}' // e.g., "/users/id}" (extra closing brace)
|
||||||
|
|
||||||
|
doc := generate_doc_with_invalid_path(invalid_path)
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message contains the invalid path
|
||||||
|
if err.msg().contains(invalid_path) {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't contain invalid path '${invalid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail("Validation passed for invalid path '${invalid_path}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Path with empty parameter '{}' should return error mentioning the path
|
||||||
|
fn test_path_with_empty_parameter() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 4: Path with empty parameter validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate invalid path with empty parameter
|
||||||
|
invalid_path := '/${random_string(3, 10)}/{}' // e.g., "/users/{}" (empty parameter)
|
||||||
|
|
||||||
|
doc := generate_doc_with_invalid_path(invalid_path)
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message contains the invalid path
|
||||||
|
if err.msg().contains(invalid_path) {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't contain invalid path '${invalid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail("Validation passed for invalid path '${invalid_path}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Path with consecutive slashes should return error mentioning the path
|
||||||
|
fn test_path_with_consecutive_slashes() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 5: Path with consecutive slashes validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate invalid path with consecutive slashes
|
||||||
|
invalid_path := '/${random_string(3, 10)}//${random_string(3, 10)}' // e.g., "/users//profile"
|
||||||
|
|
||||||
|
doc := generate_doc_with_invalid_path(invalid_path)
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message contains the invalid path
|
||||||
|
if err.msg().contains(invalid_path) {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't contain invalid path '${invalid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail("Validation passed for invalid path '${invalid_path}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Valid paths should pass validation
|
||||||
|
fn test_valid_paths_pass() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 6: Valid paths pass validation')
|
||||||
|
|
||||||
|
valid_path_patterns := [
|
||||||
|
'/users',
|
||||||
|
'/users/{id}',
|
||||||
|
'/api/v1/users',
|
||||||
|
'/api/v1/users/{userId}/posts/{postId}',
|
||||||
|
'/{resource}',
|
||||||
|
'/health',
|
||||||
|
'/api/v2/items/{itemId}/details',
|
||||||
|
]
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Pick a random valid path pattern
|
||||||
|
valid_path := valid_path_patterns[rand.int_in_range(0, valid_path_patterns.len) or { 0 }]
|
||||||
|
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
valid_path: generate_valid_path_item()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate should pass
|
||||||
|
doc.validate() or {
|
||||||
|
stats.record_fail("Validation failed for valid path '${valid_path}': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.record_pass()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: Invalid Path Error Identification')
|
||||||
|
println('==========================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
mut total_stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
stats1 := test_path_not_starting_with_slash()
|
||||||
|
total_stats.total_tests += stats1.total_tests
|
||||||
|
total_stats.passed_tests += stats1.passed_tests
|
||||||
|
total_stats.failed_tests += stats1.failed_tests
|
||||||
|
total_stats.failed_examples << stats1.failed_examples
|
||||||
|
|
||||||
|
stats2 := test_path_with_unmatched_open_brace()
|
||||||
|
total_stats.total_tests += stats2.total_tests
|
||||||
|
total_stats.passed_tests += stats2.passed_tests
|
||||||
|
total_stats.failed_tests += stats2.failed_tests
|
||||||
|
total_stats.failed_examples << stats2.failed_examples
|
||||||
|
|
||||||
|
stats3 := test_path_with_unmatched_close_brace()
|
||||||
|
total_stats.total_tests += stats3.total_tests
|
||||||
|
total_stats.passed_tests += stats3.passed_tests
|
||||||
|
total_stats.failed_tests += stats3.failed_tests
|
||||||
|
total_stats.failed_examples << stats3.failed_examples
|
||||||
|
|
||||||
|
stats4 := test_path_with_empty_parameter()
|
||||||
|
total_stats.total_tests += stats4.total_tests
|
||||||
|
total_stats.passed_tests += stats4.passed_tests
|
||||||
|
total_stats.failed_tests += stats4.failed_tests
|
||||||
|
total_stats.failed_examples << stats4.failed_examples
|
||||||
|
|
||||||
|
stats5 := test_path_with_consecutive_slashes()
|
||||||
|
total_stats.total_tests += stats5.total_tests
|
||||||
|
total_stats.passed_tests += stats5.passed_tests
|
||||||
|
total_stats.failed_tests += stats5.failed_tests
|
||||||
|
total_stats.failed_examples << stats5.failed_examples
|
||||||
|
|
||||||
|
stats6 := test_valid_paths_pass()
|
||||||
|
total_stats.total_tests += stats6.total_tests
|
||||||
|
total_stats.passed_tests += stats6.passed_tests
|
||||||
|
total_stats.failed_tests += stats6.failed_tests
|
||||||
|
total_stats.failed_examples << stats6.failed_examples
|
||||||
|
|
||||||
|
total_stats.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if total_stats.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// test_openapi_optional_fields_property.v - Property-Based Test for Optional Fields Omission
|
||||||
|
// **Feature: swagger-ui, Property 8: Optional Fields Omission in JSON**
|
||||||
|
// **Validates: Requirements 3.4**
|
||||||
|
//
|
||||||
|
// Property: For any OpenAPI document with optional fields not set,
|
||||||
|
// the serialized JSON SHALL NOT contain those fields.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Empty description should not appear in JSON
|
||||||
|
fn test_empty_description_omitted() bool {
|
||||||
|
info := hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
// description is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := info.to_json().str()
|
||||||
|
return !json_str.contains('"description"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Empty contact should not appear in JSON
|
||||||
|
fn test_empty_contact_omitted() bool {
|
||||||
|
info := hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
// contact is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := info.to_json().str()
|
||||||
|
return !json_str.contains('"contact"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Empty license should not appear in JSON
|
||||||
|
fn test_empty_license_omitted() bool {
|
||||||
|
info := hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
// license is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := info.to_json().str()
|
||||||
|
return !json_str.contains('"license"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Empty servers array should not appear in JSON
|
||||||
|
fn test_empty_servers_omitted() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
// servers is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
return !json_str.contains('"servers"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Empty tags array should not appear in JSON
|
||||||
|
fn test_empty_tags_omitted() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
// tags is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
return !json_str.contains('"tags"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Empty components should not appear in JSON
|
||||||
|
fn test_empty_components_omitted() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 15)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
// components is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
return !json_str.contains('"components"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: False boolean values should not appear in JSON (for optional booleans)
|
||||||
|
fn test_false_boolean_omitted() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: random_string(3, 10)
|
||||||
|
in_location: 'query'
|
||||||
|
required: false // should be omitted
|
||||||
|
deprecated: false // should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := param.to_json().str()
|
||||||
|
no_required := !json_str.contains('"required"')
|
||||||
|
no_deprecated := !json_str.contains('"deprecated"')
|
||||||
|
return no_required && no_deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: True boolean values SHOULD appear in JSON
|
||||||
|
fn test_true_boolean_included() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: random_string(3, 10)
|
||||||
|
in_location: 'query'
|
||||||
|
required: true // should be included
|
||||||
|
deprecated: true // should be included
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := param.to_json().str()
|
||||||
|
has_required := json_str.contains('"required":true')
|
||||||
|
has_deprecated := json_str.contains('"deprecated":true')
|
||||||
|
return has_required && has_deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Empty operation should not appear in path item
|
||||||
|
fn test_empty_operation_omitted() bool {
|
||||||
|
path_item := hono.OpenAPIPathItem{
|
||||||
|
summary: random_string(5, 15)
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'Get'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{description: 'OK'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// post, put, delete, etc. are empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := path_item.to_json().str()
|
||||||
|
has_get := json_str.contains('"get"')
|
||||||
|
no_post := !json_str.contains('"post"')
|
||||||
|
no_put := !json_str.contains('"put"')
|
||||||
|
no_delete := !json_str.contains('"delete"')
|
||||||
|
return has_get && no_post && no_put && no_delete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: Empty parameters array should not appear in operation
|
||||||
|
fn test_empty_parameters_omitted() bool {
|
||||||
|
op := hono.OpenAPIOperation{
|
||||||
|
summary: random_string(5, 15)
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{description: 'OK'}
|
||||||
|
}
|
||||||
|
// parameters is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := op.to_json().str()
|
||||||
|
return !json_str.contains('"parameters"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: Empty request body should not appear in operation
|
||||||
|
fn test_empty_request_body_omitted() bool {
|
||||||
|
op := hono.OpenAPIOperation{
|
||||||
|
summary: random_string(5, 15)
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{description: 'OK'}
|
||||||
|
}
|
||||||
|
// request_body is empty - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := op.to_json().str()
|
||||||
|
return !json_str.contains('"requestBody"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12: Zero numeric values should not appear (for optional numerics)
|
||||||
|
fn test_zero_numeric_omitted() bool {
|
||||||
|
schema := hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
// minimum, maximum, min_length, max_length are 0 - should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := schema.to_json().str()
|
||||||
|
no_minimum := !json_str.contains('"minimum"')
|
||||||
|
no_maximum := !json_str.contains('"maximum"')
|
||||||
|
no_min_length := !json_str.contains('"minLength"')
|
||||||
|
no_max_length := !json_str.contains('"maxLength"')
|
||||||
|
return no_minimum && no_maximum && no_min_length && no_max_length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13: Non-zero numeric values SHOULD appear
|
||||||
|
fn test_nonzero_numeric_included() bool {
|
||||||
|
schema := hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
min_length: 5
|
||||||
|
max_length: 50
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := schema.to_json().str()
|
||||||
|
has_minimum := json_str.contains('"minimum"')
|
||||||
|
has_maximum := json_str.contains('"maximum"')
|
||||||
|
has_min_length := json_str.contains('"minLength"')
|
||||||
|
has_max_length := json_str.contains('"maxLength"')
|
||||||
|
return has_minimum && has_maximum && has_min_length && has_max_length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test: Optional fields omission
|
||||||
|
fn test_optional_fields_property() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 8: Optional Fields Omission in JSON')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Test 1: Empty description
|
||||||
|
if test_empty_description_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty description was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Empty contact
|
||||||
|
if test_empty_contact_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty contact was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Empty license
|
||||||
|
if test_empty_license_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty license was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Empty servers
|
||||||
|
if test_empty_servers_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty servers was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Empty tags
|
||||||
|
if test_empty_tags_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty tags was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Empty components
|
||||||
|
if test_empty_components_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty components was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: False boolean omitted
|
||||||
|
if test_false_boolean_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: False boolean was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: True boolean included
|
||||||
|
if test_true_boolean_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: True boolean was not included')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Empty operation omitted
|
||||||
|
if test_empty_operation_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty operation was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: Empty parameters omitted
|
||||||
|
if test_empty_parameters_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty parameters was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: Empty request body omitted
|
||||||
|
if test_empty_request_body_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Empty request body was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12: Zero numeric omitted
|
||||||
|
if test_zero_numeric_omitted() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Zero numeric was not omitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13: Non-zero numeric included
|
||||||
|
if test_nonzero_numeric_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Non-zero numeric was not included')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: Optional Fields Omission')
|
||||||
|
println('=================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
stats := test_optional_fields_property()
|
||||||
|
stats.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// test_openapi_param_locations_property.v - Property-Based Test for Parameter Locations Support
|
||||||
|
// **Feature: swagger-ui, Property 5: Parameter Locations Support**
|
||||||
|
// **Validates: Requirements 2.5**
|
||||||
|
//
|
||||||
|
// Property: For any parameter location (path, query, header, cookie),
|
||||||
|
// adding a parameter with that location SHALL succeed and be preserved in the document.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random path generator
|
||||||
|
fn random_path() string {
|
||||||
|
segments := rand.int_in_range(1, 4) or { 1 }
|
||||||
|
mut path := ''
|
||||||
|
for _ in 0 .. segments {
|
||||||
|
path += '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// All supported parameter locations
|
||||||
|
const param_locations = ['path', 'query', 'header', 'cookie']
|
||||||
|
|
||||||
|
// Generate a random parameter with a specific location
|
||||||
|
fn generate_parameter(location string) hono.OpenAPIParameter {
|
||||||
|
return hono.OpenAPIParameter{
|
||||||
|
name: random_string(3, 10)
|
||||||
|
in_location: location
|
||||||
|
description: 'Test ${location} parameter - ${random_string(5, 10)}'
|
||||||
|
required: location == 'path' || rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Test that each parameter location can be added and is preserved
|
||||||
|
fn test_param_location_via_builder(location string) bool {
|
||||||
|
path := random_path()
|
||||||
|
param := generate_parameter(location)
|
||||||
|
|
||||||
|
// Create an operation with the parameter
|
||||||
|
op := hono.OpenAPIOperation{
|
||||||
|
summary: 'Test operation with ${location} parameter'
|
||||||
|
parameters: [param]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build document using the builder
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
mut path_builder := builder.path(path)
|
||||||
|
path_builder.get(op)
|
||||||
|
path_builder.done()
|
||||||
|
|
||||||
|
// Build the document
|
||||||
|
doc := builder.build() or { return false }
|
||||||
|
|
||||||
|
// Verify the path exists
|
||||||
|
if path !in doc.paths {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the parameter is preserved
|
||||||
|
path_item := doc.paths[path] or { return false }
|
||||||
|
|
||||||
|
if path_item.get.parameters.len == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
stored_param := path_item.get.parameters[0]
|
||||||
|
|
||||||
|
// Check parameter location is preserved
|
||||||
|
if stored_param.in_location != location {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parameter name is preserved
|
||||||
|
if stored_param.name != param.name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test: All parameter locations are supported
|
||||||
|
fn test_param_locations_property() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 5: Parameter Locations Support')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Pick a random parameter location
|
||||||
|
location_idx := rand.int_in_range(0, param_locations.len) or { 0 }
|
||||||
|
location := param_locations[location_idx]
|
||||||
|
|
||||||
|
if test_param_location_via_builder(location) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Location ${location} was not preserved in document')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional test: All locations can be used in the same operation
|
||||||
|
fn test_all_locations_in_same_operation() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('\nRunning additional test: All parameter locations in same operation')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
path := random_path()
|
||||||
|
|
||||||
|
// Create parameters for all locations
|
||||||
|
path_param := generate_parameter('path')
|
||||||
|
query_param := generate_parameter('query')
|
||||||
|
header_param := generate_parameter('header')
|
||||||
|
cookie_param := generate_parameter('cookie')
|
||||||
|
|
||||||
|
// Create an operation with all parameters
|
||||||
|
op := hono.OpenAPIOperation{
|
||||||
|
summary: 'Test operation with all parameter locations'
|
||||||
|
parameters: [path_param, query_param, header_param, cookie_param]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build document
|
||||||
|
mut builder := hono.OpenAPIBuilder.new()
|
||||||
|
builder.openapi('3.0.0')
|
||||||
|
builder.title('Test API')
|
||||||
|
builder.version('1.0.0')
|
||||||
|
|
||||||
|
mut path_builder := builder.path(path)
|
||||||
|
path_builder.get(op)
|
||||||
|
path_builder.done()
|
||||||
|
|
||||||
|
doc := builder.build() or {
|
||||||
|
stats.record_fail('Iteration ${i}: Build failed - ${err}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all parameters are preserved
|
||||||
|
path_item := doc.paths[path] or {
|
||||||
|
stats.record_fail('Iteration ${i}: Path not found')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_item.get.parameters.len != 4 {
|
||||||
|
stats.record_fail('Iteration ${i}: Expected 4 parameters, got ${path_item.get.parameters.len}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each location is present
|
||||||
|
mut found_locations := map[string]bool{}
|
||||||
|
for param in path_item.get.parameters {
|
||||||
|
found_locations[param.in_location] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mut all_found := true
|
||||||
|
for loc in param_locations {
|
||||||
|
if loc !in found_locations {
|
||||||
|
all_found = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_found {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Not all parameter locations preserved')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: Parameter Locations Support')
|
||||||
|
println('====================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
// Run main property test
|
||||||
|
stats1 := test_param_locations_property()
|
||||||
|
stats1.print_summary()
|
||||||
|
|
||||||
|
// Run additional test
|
||||||
|
stats2 := test_all_locations_in_same_operation()
|
||||||
|
stats2.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats1.failed_tests > 0 || stats2.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// test_openapi_required_fields_property.v - Property-Based Test for OpenAPI Required Fields Validation
|
||||||
|
// **Feature: swagger-ui, Property 3: OpenAPI Document Required Fields Validation**
|
||||||
|
// **Validates: Requirements 2.2, 8.1**
|
||||||
|
//
|
||||||
|
// Property: For any OpenAPI document missing a required field (openapi, info, or paths),
|
||||||
|
// the validate() method SHALL return an error identifying the missing field.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 5 {
|
||||||
|
println('... and ${stats.failed_examples.len - 5} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random path generator
|
||||||
|
fn random_path() string {
|
||||||
|
segments := rand.int_in_range(1, 4) or { 1 }
|
||||||
|
mut path := ''
|
||||||
|
for _ in 0 .. segments {
|
||||||
|
path += '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random OpenAPI version
|
||||||
|
fn random_openapi_version() string {
|
||||||
|
versions := ['3.0.0', '3.0.1', '3.0.2', '3.0.3', '3.1.0']
|
||||||
|
return versions[rand.int_in_range(0, versions.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a valid OpenAPIDocument
|
||||||
|
fn generate_valid_document() hono.OpenAPIDocument {
|
||||||
|
return hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '${rand.int_in_range(1, 10) or { 1 }}.${rand.int_in_range(0, 10) or { 0 }}.${rand.int_in_range(0, 10) or { 0 }}'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
random_path(): hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: random_string(5, 20)
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{description: 'Success'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Missing 'openapi' field should return error
|
||||||
|
fn test_missing_openapi_field() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 1: Missing openapi field validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate document with missing openapi field
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '' // Missing required field
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
random_path(): hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message mentions 'openapi'
|
||||||
|
if err.msg().contains('openapi') {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't mention 'openapi': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail('Validation passed for document with missing openapi field')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Missing 'info.title' field should return error
|
||||||
|
fn test_missing_info_title_field() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 2: Missing info.title field validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate document with missing info.title field
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: '' // Missing required field
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
random_path(): hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message mentions 'info.title'
|
||||||
|
if err.msg().contains('info.title') {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't mention 'info.title': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail('Validation passed for document with missing info.title field')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Missing 'info.version' field should return error
|
||||||
|
fn test_missing_info_version_field() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 3: Missing info.version field validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate document with missing info.version field
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '' // Missing required field
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
random_path(): hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message mentions 'info.version'
|
||||||
|
if err.msg().contains('info.version') {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't mention 'info.version': ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail('Validation passed for document with missing info.version field')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Valid document should pass validation
|
||||||
|
fn test_valid_document_passes() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 4: Valid document passes validation')
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Generate valid document
|
||||||
|
doc := generate_valid_document()
|
||||||
|
|
||||||
|
// Validate should pass
|
||||||
|
doc.validate() or {
|
||||||
|
stats.record_fail('Validation failed for valid document: ${err.msg()}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.record_pass()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Unsupported OpenAPI version should return error
|
||||||
|
fn test_unsupported_openapi_version() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Test 5: Unsupported OpenAPI version validation')
|
||||||
|
|
||||||
|
invalid_versions := ['2.0.0', '1.0.0', '4.0.0', '3.2.0', 'invalid', '']
|
||||||
|
|
||||||
|
for _ in 0 .. property_test_iterations {
|
||||||
|
// Pick a random invalid version
|
||||||
|
invalid_version := invalid_versions[rand.int_in_range(0, invalid_versions.len - 1) or { 0 }]
|
||||||
|
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: invalid_version
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
random_path(): hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate should fail
|
||||||
|
doc.validate() or {
|
||||||
|
// Check that error message mentions version issue
|
||||||
|
if err.msg().contains('openapi') || err.msg().contains('version') || err.msg().contains('Unsupported') {
|
||||||
|
stats.record_pass()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.record_fail("Error message doesn't mention version issue: ${err.msg()}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passed, that's a failure
|
||||||
|
stats.record_fail('Validation passed for document with unsupported version: ${invalid_version}')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: OpenAPI Required Fields Validation')
|
||||||
|
println('============================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
mut total_stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
stats1 := test_missing_openapi_field()
|
||||||
|
total_stats.total_tests += stats1.total_tests
|
||||||
|
total_stats.passed_tests += stats1.passed_tests
|
||||||
|
total_stats.failed_tests += stats1.failed_tests
|
||||||
|
total_stats.failed_examples << stats1.failed_examples
|
||||||
|
|
||||||
|
stats2 := test_missing_info_title_field()
|
||||||
|
total_stats.total_tests += stats2.total_tests
|
||||||
|
total_stats.passed_tests += stats2.passed_tests
|
||||||
|
total_stats.failed_tests += stats2.failed_tests
|
||||||
|
total_stats.failed_examples << stats2.failed_examples
|
||||||
|
|
||||||
|
stats3 := test_missing_info_version_field()
|
||||||
|
total_stats.total_tests += stats3.total_tests
|
||||||
|
total_stats.passed_tests += stats3.passed_tests
|
||||||
|
total_stats.failed_tests += stats3.failed_tests
|
||||||
|
total_stats.failed_examples << stats3.failed_examples
|
||||||
|
|
||||||
|
stats4 := test_valid_document_passes()
|
||||||
|
total_stats.total_tests += stats4.total_tests
|
||||||
|
total_stats.passed_tests += stats4.passed_tests
|
||||||
|
total_stats.failed_tests += stats4.failed_tests
|
||||||
|
total_stats.failed_examples << stats4.failed_examples
|
||||||
|
|
||||||
|
stats5 := test_unsupported_openapi_version()
|
||||||
|
total_stats.total_tests += stats5.total_tests
|
||||||
|
total_stats.passed_tests += stats5.passed_tests
|
||||||
|
total_stats.failed_tests += stats5.failed_tests
|
||||||
|
total_stats.failed_examples << stats5.failed_examples
|
||||||
|
|
||||||
|
total_stats.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if total_stats.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// test_openapi_roundtrip_property.v - Property-Based Test for OpenAPI Round-Trip Serialization
|
||||||
|
// **Feature: swagger-ui, Property 1: OpenAPI Document Round-Trip Serialization**
|
||||||
|
// **Validates: Requirements 3.2**
|
||||||
|
//
|
||||||
|
// Property: For any valid OpenAPI document, serializing to JSON and then
|
||||||
|
// deserializing back SHALL produce an equivalent document.
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random URL generator
|
||||||
|
fn random_url() string {
|
||||||
|
protocols := ['http', 'https']
|
||||||
|
protocol := protocols[rand.int_in_range(0, 2) or { 0 }]
|
||||||
|
domain := random_string(3, 10)
|
||||||
|
tld := ['com', 'org', 'net', 'io'][rand.int_in_range(0, 4) or { 0 }]
|
||||||
|
return '${protocol}://${domain}.${tld}'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random email generator
|
||||||
|
fn random_email() string {
|
||||||
|
return '${random_string(3, 8)}@${random_string(3, 6)}.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random path generator
|
||||||
|
fn random_path() string {
|
||||||
|
segments := rand.int_in_range(1, 4) or { 1 }
|
||||||
|
mut path := ''
|
||||||
|
for _ in 0 .. segments {
|
||||||
|
path += '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random OpenAPI version
|
||||||
|
fn random_openapi_version() string {
|
||||||
|
versions := ['3.0.0', '3.0.1', '3.0.2', '3.0.3', '3.1.0']
|
||||||
|
return versions[rand.int_in_range(0, versions.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random HTTP method
|
||||||
|
fn random_http_method() string {
|
||||||
|
methods := ['get', 'post', 'put', 'delete', 'patch']
|
||||||
|
return methods[rand.int_in_range(0, methods.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random parameter location
|
||||||
|
fn random_param_location() string {
|
||||||
|
locations := ['path', 'query', 'header', 'cookie']
|
||||||
|
return locations[rand.int_in_range(0, locations.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random schema type
|
||||||
|
fn random_schema_type() string {
|
||||||
|
types := ['string', 'integer', 'number', 'boolean', 'array', 'object']
|
||||||
|
return types[rand.int_in_range(0, types.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIContact
|
||||||
|
fn generate_random_contact() hono.OpenAPIContact {
|
||||||
|
return hono.OpenAPIContact{
|
||||||
|
name: random_string(3, 15)
|
||||||
|
url: random_url()
|
||||||
|
email: random_email()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPILicense
|
||||||
|
fn generate_random_license() hono.OpenAPILicense {
|
||||||
|
licenses := ['MIT', 'Apache-2.0', 'GPL-3.0', 'BSD-3-Clause']
|
||||||
|
return hono.OpenAPILicense{
|
||||||
|
name: licenses[rand.int_in_range(0, licenses.len) or { 0 }]
|
||||||
|
url: random_url()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIInfo
|
||||||
|
fn generate_random_info() hono.OpenAPIInfo {
|
||||||
|
return hono.OpenAPIInfo{
|
||||||
|
title: random_string(5, 20)
|
||||||
|
version: '${rand.int_in_range(1, 10) or { 1 }}.${rand.int_in_range(0, 10) or { 0 }}.${rand.int_in_range(0, 10) or { 0 }}'
|
||||||
|
description: random_string(10, 50)
|
||||||
|
terms_of_service: random_url()
|
||||||
|
contact: generate_random_contact()
|
||||||
|
license: generate_random_license()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIServer
|
||||||
|
fn generate_random_server() hono.OpenAPIServer {
|
||||||
|
return hono.OpenAPIServer{
|
||||||
|
url: random_url()
|
||||||
|
description: random_string(5, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPISchema (simple, non-recursive)
|
||||||
|
fn generate_random_schema() hono.OpenAPISchema {
|
||||||
|
schema_type := random_schema_type()
|
||||||
|
return hono.OpenAPISchema{
|
||||||
|
schema_type: schema_type
|
||||||
|
format: if schema_type == 'integer' { 'int64' } else if schema_type == 'number' { 'double' } else { '' }
|
||||||
|
description: random_string(5, 20)
|
||||||
|
example: random_string(3, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIParameter
|
||||||
|
fn generate_random_parameter() hono.OpenAPIParameter {
|
||||||
|
location := random_param_location()
|
||||||
|
return hono.OpenAPIParameter{
|
||||||
|
name: random_string(3, 10)
|
||||||
|
in_location: location
|
||||||
|
description: random_string(5, 20)
|
||||||
|
required: location == 'path' || rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
deprecated: rand.int_in_range(0, 10) or { 0 } == 0 // 10% chance
|
||||||
|
schema: generate_random_schema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIResponse
|
||||||
|
fn generate_random_response() hono.OpenAPIResponse {
|
||||||
|
return hono.OpenAPIResponse{
|
||||||
|
description: random_string(5, 30)
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: generate_random_schema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIOperation
|
||||||
|
fn generate_random_operation() hono.OpenAPIOperation {
|
||||||
|
num_params := rand.int_in_range(0, 3) or { 0 }
|
||||||
|
mut params := []hono.OpenAPIParameter{}
|
||||||
|
for _ in 0 .. num_params {
|
||||||
|
params << generate_random_parameter()
|
||||||
|
}
|
||||||
|
|
||||||
|
num_tags := rand.int_in_range(0, 3) or { 0 }
|
||||||
|
mut tags := []string{}
|
||||||
|
for _ in 0 .. num_tags {
|
||||||
|
tags << random_string(3, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hono.OpenAPIOperation{
|
||||||
|
summary: random_string(5, 30)
|
||||||
|
description: random_string(10, 50)
|
||||||
|
operation_id: random_string(5, 15)
|
||||||
|
tags: tags
|
||||||
|
parameters: params
|
||||||
|
responses: {
|
||||||
|
'200': generate_random_response()
|
||||||
|
'400': hono.OpenAPIResponse{description: 'Bad Request'}
|
||||||
|
'500': hono.OpenAPIResponse{description: 'Internal Server Error'}
|
||||||
|
}
|
||||||
|
deprecated: rand.int_in_range(0, 10) or { 0 } == 0 // 10% chance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIPathItem
|
||||||
|
fn generate_random_path_item() hono.OpenAPIPathItem {
|
||||||
|
mut path_item := hono.OpenAPIPathItem{
|
||||||
|
summary: random_string(5, 20)
|
||||||
|
description: random_string(10, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly add HTTP methods
|
||||||
|
if rand.int_in_range(0, 2) or { 0 } == 1 {
|
||||||
|
path_item.get = generate_random_operation()
|
||||||
|
}
|
||||||
|
if rand.int_in_range(0, 2) or { 0 } == 1 {
|
||||||
|
path_item.post = generate_random_operation()
|
||||||
|
}
|
||||||
|
if rand.int_in_range(0, 3) or { 0 } == 0 {
|
||||||
|
path_item.put = generate_random_operation()
|
||||||
|
}
|
||||||
|
if rand.int_in_range(0, 3) or { 0 } == 0 {
|
||||||
|
path_item.delete = generate_random_operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure at least one method is set
|
||||||
|
if path_item.get.responses.len == 0 && path_item.post.responses.len == 0 &&
|
||||||
|
path_item.put.responses.len == 0 && path_item.delete.responses.len == 0 {
|
||||||
|
path_item.get = generate_random_operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return path_item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPITag
|
||||||
|
fn generate_random_tag() hono.OpenAPITag {
|
||||||
|
return hono.OpenAPITag{
|
||||||
|
name: random_string(3, 10)
|
||||||
|
description: random_string(10, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random OpenAPIDocument
|
||||||
|
fn generate_random_document() hono.OpenAPIDocument {
|
||||||
|
// Generate random number of paths (1-5)
|
||||||
|
num_paths := rand.int_in_range(1, 6) or { 1 }
|
||||||
|
mut paths := map[string]hono.OpenAPIPathItem{}
|
||||||
|
for _ in 0 .. num_paths {
|
||||||
|
paths[random_path()] = generate_random_path_item()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random number of servers (0-3)
|
||||||
|
num_servers := rand.int_in_range(0, 4) or { 0 }
|
||||||
|
mut servers := []hono.OpenAPIServer{}
|
||||||
|
for _ in 0 .. num_servers {
|
||||||
|
servers << generate_random_server()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random number of tags (0-5)
|
||||||
|
num_tags := rand.int_in_range(0, 6) or { 0 }
|
||||||
|
mut tags := []hono.OpenAPITag{}
|
||||||
|
for _ in 0 .. num_tags {
|
||||||
|
tags << generate_random_tag()
|
||||||
|
}
|
||||||
|
|
||||||
|
return hono.OpenAPIDocument{
|
||||||
|
openapi: random_openapi_version()
|
||||||
|
info: generate_random_info()
|
||||||
|
servers: servers
|
||||||
|
paths: paths
|
||||||
|
tags: tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPIInfo objects
|
||||||
|
fn compare_info(a hono.OpenAPIInfo, b hono.OpenAPIInfo) bool {
|
||||||
|
return a.title == b.title &&
|
||||||
|
a.version == b.version &&
|
||||||
|
a.description == b.description &&
|
||||||
|
a.terms_of_service == b.terms_of_service &&
|
||||||
|
a.contact.name == b.contact.name &&
|
||||||
|
a.contact.url == b.contact.url &&
|
||||||
|
a.contact.email == b.contact.email &&
|
||||||
|
a.license.name == b.license.name &&
|
||||||
|
a.license.url == b.license.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPISchema objects
|
||||||
|
fn compare_schema(a hono.OpenAPISchema, b hono.OpenAPISchema) bool {
|
||||||
|
return a.schema_type == b.schema_type &&
|
||||||
|
a.format == b.format &&
|
||||||
|
a.description == b.description &&
|
||||||
|
a.example == b.example
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPIParameter objects
|
||||||
|
fn compare_parameter(a hono.OpenAPIParameter, b hono.OpenAPIParameter) bool {
|
||||||
|
return a.name == b.name &&
|
||||||
|
a.in_location == b.in_location &&
|
||||||
|
a.description == b.description &&
|
||||||
|
a.required == b.required &&
|
||||||
|
a.deprecated == b.deprecated &&
|
||||||
|
compare_schema(a.schema, b.schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPIOperation objects
|
||||||
|
fn compare_operation(a hono.OpenAPIOperation, b hono.OpenAPIOperation) bool {
|
||||||
|
if a.summary != b.summary || a.description != b.description ||
|
||||||
|
a.operation_id != b.operation_id || a.deprecated != b.deprecated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.tags.len != b.tags.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, tag in a.tags {
|
||||||
|
if tag != b.tags[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.parameters.len != b.parameters.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, param in a.parameters {
|
||||||
|
if !compare_parameter(param, b.parameters[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.responses.len != b.responses.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPIPathItem objects
|
||||||
|
fn compare_path_item(a hono.OpenAPIPathItem, b hono.OpenAPIPathItem) bool {
|
||||||
|
if a.summary != b.summary || a.description != b.description {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare operations (only if they have responses, meaning they're set)
|
||||||
|
if a.get.responses.len > 0 || b.get.responses.len > 0 {
|
||||||
|
if !compare_operation(a.get, b.get) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.post.responses.len > 0 || b.post.responses.len > 0 {
|
||||||
|
if !compare_operation(a.post, b.post) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.put.responses.len > 0 || b.put.responses.len > 0 {
|
||||||
|
if !compare_operation(a.put, b.put) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.delete.responses.len > 0 || b.delete.responses.len > 0 {
|
||||||
|
if !compare_operation(a.delete, b.delete) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two OpenAPIDocument objects
|
||||||
|
fn compare_documents(original hono.OpenAPIDocument, restored hono.OpenAPIDocument) bool {
|
||||||
|
// Compare basic fields
|
||||||
|
if original.openapi != restored.openapi {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare info
|
||||||
|
if !compare_info(original.info, restored.info) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare servers
|
||||||
|
if original.servers.len != restored.servers.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, server in original.servers {
|
||||||
|
if server.url != restored.servers[i].url ||
|
||||||
|
server.description != restored.servers[i].description {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare paths
|
||||||
|
if original.paths.len != restored.paths.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for path, path_item in original.paths {
|
||||||
|
if path !in restored.paths {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
restored_item := restored.paths[path] or { return false }
|
||||||
|
if !compare_path_item(path_item, restored_item) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare tags
|
||||||
|
if original.tags.len != restored.tags.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, tag in original.tags {
|
||||||
|
if tag.name != restored.tags[i].name ||
|
||||||
|
tag.description != restored.tags[i].description {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test: Round-trip serialization
|
||||||
|
fn test_round_trip_property() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 1: OpenAPI Document Round-Trip Serialization')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Generate random document
|
||||||
|
original := generate_random_document()
|
||||||
|
|
||||||
|
// Serialize to JSON
|
||||||
|
json_str := original.to_json_str()
|
||||||
|
|
||||||
|
// Deserialize back
|
||||||
|
restored := hono.OpenAPIDocument.from_json_str(json_str) or {
|
||||||
|
stats.record_fail('Iteration ${i}: Deserialization failed - ${err}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare
|
||||||
|
if compare_documents(original, restored) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Documents not equal after round-trip. OpenAPI version: ${original.openapi}, Paths: ${original.paths.len}')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: OpenAPI Round-Trip Serialization')
|
||||||
|
println('=========================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
stats := test_round_trip_property()
|
||||||
|
stats.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// test_openapi_serialization.v - 测试 OpenAPI 序列化和反序列化
|
||||||
|
// **Feature: swagger-ui, Property 1: OpenAPI Document Round-Trip Serialization**
|
||||||
|
// **Validates: Requirements 3.1, 3.2, 3.4**
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import x.json2
|
||||||
|
|
||||||
|
struct TestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats TestStats) run_test(test_name string, test_func fn () bool) {
|
||||||
|
stats.total_tests++
|
||||||
|
print('🧪 ${test_name}... ')
|
||||||
|
|
||||||
|
if test_func() {
|
||||||
|
stats.passed_tests++
|
||||||
|
println('✅')
|
||||||
|
} else {
|
||||||
|
stats.failed_tests++
|
||||||
|
println('❌')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats TestStats) print_summary() {
|
||||||
|
println('\n=== OpenAPI 序列化测试总结 ===')
|
||||||
|
println('总测试数: ${stats.total_tests}')
|
||||||
|
println('通过: ${stats.passed_tests}')
|
||||||
|
println('失败: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 所有测试通过!')
|
||||||
|
} else {
|
||||||
|
println('⚠️ 有 ${stats.failed_tests} 个测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 1: OpenAPIDocument to_json_str 基本功能
|
||||||
|
fn test_document_to_json_str() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test API'
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
'/users': hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'Get users'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
has_openapi := json_str.contains('"openapi":"3.0.0"')
|
||||||
|
has_title := json_str.contains('"title":"Test API"')
|
||||||
|
has_users := json_str.contains('"/users"')
|
||||||
|
return has_openapi && has_title && has_users
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 2: 字段名映射 (schema_type -> type, in_location -> in)
|
||||||
|
fn test_field_name_mapping() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: 'id'
|
||||||
|
in_location: 'path'
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_any := param.to_json()
|
||||||
|
json_str := json_any.str()
|
||||||
|
|
||||||
|
// Should use 'in' not 'in_location', 'type' not 'schema_type'
|
||||||
|
has_in := json_str.contains('"in":"path"')
|
||||||
|
has_type := json_str.contains('"type":"integer"')
|
||||||
|
no_in_location := !json_str.contains('in_location')
|
||||||
|
no_schema_type := !json_str.contains('schema_type')
|
||||||
|
return has_in && has_type && no_in_location && no_schema_type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 3: ref 字段映射
|
||||||
|
fn test_ref_field_mapping() bool {
|
||||||
|
schema := hono.OpenAPISchema{
|
||||||
|
ref: '#/components/schemas/User'
|
||||||
|
}
|
||||||
|
|
||||||
|
json_any := schema.to_json()
|
||||||
|
json_str := json_any.str()
|
||||||
|
|
||||||
|
return json_str.contains(r'"$ref":"#/components/schemas/User"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 4: 可选字段省略 - 空字符串不应出现
|
||||||
|
fn test_optional_fields_omission_strings() bool {
|
||||||
|
info := hono.OpenAPIInfo{
|
||||||
|
title: 'Test'
|
||||||
|
version: '1.0.0'
|
||||||
|
// description is empty, should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_any := info.to_json()
|
||||||
|
json_str := json_any.str()
|
||||||
|
|
||||||
|
no_desc := !json_str.contains('"description"')
|
||||||
|
has_title := json_str.contains('"title":"Test"')
|
||||||
|
has_version := json_str.contains('"version":"1.0.0"')
|
||||||
|
return no_desc && has_title && has_version
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 5: 可选字段省略 - 空数组不应出现
|
||||||
|
fn test_optional_fields_omission_arrays() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test'
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
// servers is empty, should be omitted
|
||||||
|
// tags is empty, should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
|
||||||
|
no_servers := !json_str.contains('"servers"')
|
||||||
|
no_tags := !json_str.contains('"tags"')
|
||||||
|
return no_servers && no_tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 6: 可选字段省略 - 空 map 不应出现
|
||||||
|
fn test_optional_fields_omission_maps() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test'
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
// components is empty, should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
|
||||||
|
return !json_str.contains('"components"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 7: 布尔值 false 不应出现 (除非必需)
|
||||||
|
fn test_optional_boolean_omission() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: 'test'
|
||||||
|
in_location: 'query'
|
||||||
|
required: false // should be omitted
|
||||||
|
deprecated: false // should be omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
json_any := param.to_json()
|
||||||
|
json_str := json_any.str()
|
||||||
|
|
||||||
|
no_required := !json_str.contains('"required"')
|
||||||
|
no_deprecated := !json_str.contains('"deprecated"')
|
||||||
|
return no_required && no_deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 8: 布尔值 true 应该出现
|
||||||
|
fn test_boolean_true_included() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: 'test'
|
||||||
|
in_location: 'query'
|
||||||
|
required: true
|
||||||
|
deprecated: true
|
||||||
|
}
|
||||||
|
|
||||||
|
json_any := param.to_json()
|
||||||
|
json_str := json_any.str()
|
||||||
|
|
||||||
|
has_required := json_str.contains('"required":true')
|
||||||
|
has_deprecated := json_str.contains('"deprecated":true')
|
||||||
|
return has_required && has_deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 9: 嵌套结构序列化
|
||||||
|
fn test_nested_structure_serialization() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test API'
|
||||||
|
version: '1.0.0'
|
||||||
|
contact: hono.OpenAPIContact{
|
||||||
|
name: 'Support'
|
||||||
|
email: 'support@example.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
'/users': hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'Get users'
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'limit'
|
||||||
|
in_location: 'query'
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'array'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str := doc.to_json_str()
|
||||||
|
|
||||||
|
has_contact := json_str.contains('"contact"')
|
||||||
|
has_email := json_str.contains('"email":"support@example.com"')
|
||||||
|
has_params := json_str.contains('"parameters"')
|
||||||
|
has_content := json_str.contains('"content"')
|
||||||
|
has_json := json_str.contains('"application/json"')
|
||||||
|
return has_contact && has_email && has_params && has_content && has_json
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 10: to_json_pretty 格式化输出
|
||||||
|
fn test_to_json_pretty() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test'
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
paths: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty_json := doc.to_json_pretty()
|
||||||
|
|
||||||
|
// Pretty JSON should contain newlines and indentation
|
||||||
|
has_newline := pretty_json.contains('\n')
|
||||||
|
has_indent := pretty_json.contains(' ')
|
||||||
|
return has_newline && has_indent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 11: 反序列化基本功能
|
||||||
|
fn test_from_json_basic() bool {
|
||||||
|
json_str := '{"openapi":"3.0.0","info":{"title":"Test API","version":"1.0.0"},"paths":{}}'
|
||||||
|
|
||||||
|
doc := hono.OpenAPIDocument.from_json_str(json_str) or {
|
||||||
|
println('Error: ${err}')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ok_openapi := doc.openapi == '3.0.0'
|
||||||
|
ok_title := doc.info.title == 'Test API'
|
||||||
|
ok_version := doc.info.version == '1.0.0'
|
||||||
|
return ok_openapi && ok_title && ok_version
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 12: 反序列化嵌套结构
|
||||||
|
fn test_from_json_nested() bool {
|
||||||
|
json_str := '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0.0","contact":{"name":"Support","email":"test@example.com"}},"paths":{"/users":{"get":{"summary":"Get users","responses":{"200":{"description":"OK"}}}}}}'
|
||||||
|
|
||||||
|
doc := hono.OpenAPIDocument.from_json_str(json_str) or {
|
||||||
|
println('Error: ${err}')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ok_contact_name := doc.info.contact.name == 'Support'
|
||||||
|
ok_contact_email := doc.info.contact.email == 'test@example.com'
|
||||||
|
ok_has_users := '/users' in doc.paths
|
||||||
|
users_path := doc.paths['/users'] or { return false }
|
||||||
|
ok_summary := users_path.get.summary == 'Get users'
|
||||||
|
return ok_contact_name && ok_contact_email && ok_has_users && ok_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 13: 反序列化字段名映射 (in -> in_location, type -> schema_type)
|
||||||
|
fn test_from_json_field_mapping() bool {
|
||||||
|
json_str := '{"name":"id","in":"path","schema":{"type":"integer"}}'
|
||||||
|
|
||||||
|
parsed := json2.decode[json2.Any](json_str) or {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
param := hono.OpenAPIParameter.from_json(parsed)
|
||||||
|
|
||||||
|
ok_name := param.name == 'id'
|
||||||
|
ok_in := param.in_location == 'path'
|
||||||
|
ok_type := param.schema.schema_type == 'integer'
|
||||||
|
return ok_name && ok_in && ok_type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 14: 往返序列化 - 简单文档
|
||||||
|
fn test_round_trip_simple() bool {
|
||||||
|
original := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Test API'
|
||||||
|
version: '1.0.0'
|
||||||
|
description: 'A test API'
|
||||||
|
}
|
||||||
|
paths: {
|
||||||
|
'/health': hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'Health check'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'OK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize
|
||||||
|
json_str := original.to_json_str()
|
||||||
|
|
||||||
|
// Deserialize
|
||||||
|
restored := hono.OpenAPIDocument.from_json_str(json_str) or {
|
||||||
|
println('Error: ${err}')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare
|
||||||
|
ok_openapi := restored.openapi == original.openapi
|
||||||
|
ok_title := restored.info.title == original.info.title
|
||||||
|
ok_version := restored.info.version == original.info.version
|
||||||
|
ok_desc := restored.info.description == original.info.description
|
||||||
|
ok_has_health := '/health' in restored.paths
|
||||||
|
restored_health := restored.paths['/health'] or { return false }
|
||||||
|
original_health := original.paths['/health'] or { return false }
|
||||||
|
ok_summary := restored_health.get.summary == original_health.get.summary
|
||||||
|
return ok_openapi && ok_title && ok_version && ok_desc && ok_has_health && ok_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 15: 往返序列化 - 复杂文档
|
||||||
|
fn test_round_trip_complex() bool {
|
||||||
|
original := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.1.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'Complex API'
|
||||||
|
version: '2.0.0'
|
||||||
|
description: 'A complex test API'
|
||||||
|
contact: hono.OpenAPIContact{
|
||||||
|
name: 'API Support'
|
||||||
|
email: 'support@example.com'
|
||||||
|
url: 'https://example.com/support'
|
||||||
|
}
|
||||||
|
license: hono.OpenAPILicense{
|
||||||
|
name: 'MIT'
|
||||||
|
url: 'https://opensource.org/licenses/MIT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers: [
|
||||||
|
hono.OpenAPIServer{
|
||||||
|
url: 'https://api.example.com'
|
||||||
|
description: 'Production'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
paths: {
|
||||||
|
'/users': hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'List users'
|
||||||
|
operation_id: 'listUsers'
|
||||||
|
tags: ['users']
|
||||||
|
parameters: [
|
||||||
|
hono.OpenAPIParameter{
|
||||||
|
name: 'limit'
|
||||||
|
in_location: 'query'
|
||||||
|
required: false
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'array'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post: hono.OpenAPIOperation{
|
||||||
|
summary: 'Create user'
|
||||||
|
operation_id: 'createUser'
|
||||||
|
tags: ['users']
|
||||||
|
request_body: hono.OpenAPIRequestBody{
|
||||||
|
required: true
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
required: ['name', 'email']
|
||||||
|
properties: {
|
||||||
|
'name': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
}
|
||||||
|
'email': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
format: 'email'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
'201': hono.OpenAPIResponse{
|
||||||
|
description: 'Created'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags: [
|
||||||
|
hono.OpenAPITag{
|
||||||
|
name: 'users'
|
||||||
|
description: 'User operations'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize
|
||||||
|
json_str := original.to_json_str()
|
||||||
|
|
||||||
|
// Deserialize
|
||||||
|
restored := hono.OpenAPIDocument.from_json_str(json_str) or {
|
||||||
|
println('Error: ${err}')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare key fields
|
||||||
|
ok_openapi := restored.openapi == original.openapi
|
||||||
|
ok_title := restored.info.title == original.info.title
|
||||||
|
ok_contact := restored.info.contact.name == original.info.contact.name
|
||||||
|
ok_license := restored.info.license.name == original.info.license.name
|
||||||
|
ok_servers_len := restored.servers.len == original.servers.len
|
||||||
|
ok_server_url := restored.servers[0].url == original.servers[0].url
|
||||||
|
ok_has_users := '/users' in restored.paths
|
||||||
|
restored_users := restored.paths['/users'] or { return false }
|
||||||
|
ok_get_op := restored_users.get.operation_id == 'listUsers'
|
||||||
|
ok_post_op := restored_users.post.operation_id == 'createUser'
|
||||||
|
ok_tags_len := restored.tags.len == original.tags.len
|
||||||
|
ok_tag_name := restored.tags[0].name == 'users'
|
||||||
|
|
||||||
|
return ok_openapi && ok_title && ok_contact && ok_license && ok_servers_len && ok_server_url && ok_has_users && ok_get_op && ok_post_op && ok_tags_len && ok_tag_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🚀 开始 OpenAPI 序列化测试...\n')
|
||||||
|
|
||||||
|
mut stats := TestStats{}
|
||||||
|
|
||||||
|
// 序列化测试
|
||||||
|
stats.run_test('to_json_str 基本功能', test_document_to_json_str)
|
||||||
|
stats.run_test('字段名映射 (schema_type->type, in_location->in)', test_field_name_mapping)
|
||||||
|
stats.run_test('ref 字段映射', test_ref_field_mapping)
|
||||||
|
stats.run_test('可选字段省略 - 空字符串', test_optional_fields_omission_strings)
|
||||||
|
stats.run_test('可选字段省略 - 空数组', test_optional_fields_omission_arrays)
|
||||||
|
stats.run_test('可选字段省略 - 空 map', test_optional_fields_omission_maps)
|
||||||
|
stats.run_test('布尔值 false 省略', test_optional_boolean_omission)
|
||||||
|
stats.run_test('布尔值 true 包含', test_boolean_true_included)
|
||||||
|
stats.run_test('嵌套结构序列化', test_nested_structure_serialization)
|
||||||
|
stats.run_test('to_json_pretty 格式化', test_to_json_pretty)
|
||||||
|
|
||||||
|
// 反序列化测试
|
||||||
|
stats.run_test('from_json 基本功能', test_from_json_basic)
|
||||||
|
stats.run_test('from_json 嵌套结构', test_from_json_nested)
|
||||||
|
stats.run_test('from_json 字段名映射', test_from_json_field_mapping)
|
||||||
|
|
||||||
|
// 往返测试
|
||||||
|
stats.run_test('往返序列化 - 简单文档', test_round_trip_simple)
|
||||||
|
stats.run_test('往返序列化 - 复杂文档', test_round_trip_complex)
|
||||||
|
|
||||||
|
// 打印测试总结
|
||||||
|
stats.print_summary()
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// test_openapi_structures.v - 测试 OpenAPI 数据结构
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
// OpenAPI 数据结构测试
|
||||||
|
// 测试 OpenAPI 3.0/3.1 规范的数据结构定义
|
||||||
|
|
||||||
|
struct TestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats TestStats) run_test(test_name string, test_func fn () bool) {
|
||||||
|
stats.total_tests++
|
||||||
|
print('🧪 ${test_name}... ')
|
||||||
|
|
||||||
|
if test_func() {
|
||||||
|
stats.passed_tests++
|
||||||
|
println('✅')
|
||||||
|
} else {
|
||||||
|
stats.failed_tests++
|
||||||
|
println('❌')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats TestStats) print_summary() {
|
||||||
|
println('\n=== OpenAPI 数据结构测试总结 ===')
|
||||||
|
println('总测试数: ${stats.total_tests}')
|
||||||
|
println('通过: ${stats.passed_tests}')
|
||||||
|
println('失败: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 所有测试通过!')
|
||||||
|
} else {
|
||||||
|
println('⚠️ 有 ${stats.failed_tests} 个测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 1: OpenAPIContact 结构体
|
||||||
|
fn test_openapi_contact() bool {
|
||||||
|
contact := hono.OpenAPIContact{
|
||||||
|
name: 'API Support'
|
||||||
|
url: 'https://example.com/support'
|
||||||
|
email: 'support@example.com'
|
||||||
|
}
|
||||||
|
return contact.name == 'API Support' &&
|
||||||
|
contact.url == 'https://example.com/support' &&
|
||||||
|
contact.email == 'support@example.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 2: OpenAPILicense 结构体
|
||||||
|
fn test_openapi_license() bool {
|
||||||
|
license := hono.OpenAPILicense{
|
||||||
|
name: 'MIT'
|
||||||
|
url: 'https://opensource.org/licenses/MIT'
|
||||||
|
}
|
||||||
|
return license.name == 'MIT' &&
|
||||||
|
license.url == 'https://opensource.org/licenses/MIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 3: OpenAPIInfo 结构体
|
||||||
|
fn test_openapi_info() bool {
|
||||||
|
info := hono.OpenAPIInfo{
|
||||||
|
title: 'My API'
|
||||||
|
version: '1.0.0'
|
||||||
|
description: 'A sample API'
|
||||||
|
terms_of_service: 'https://example.com/tos'
|
||||||
|
contact: hono.OpenAPIContact{
|
||||||
|
name: 'Support'
|
||||||
|
}
|
||||||
|
license: hono.OpenAPILicense{
|
||||||
|
name: 'MIT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info.title == 'My API' &&
|
||||||
|
info.version == '1.0.0' &&
|
||||||
|
info.description == 'A sample API'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 4: OpenAPIServer 结构体
|
||||||
|
fn test_openapi_server() bool {
|
||||||
|
server := hono.OpenAPIServer{
|
||||||
|
url: 'https://api.example.com'
|
||||||
|
description: 'Production server'
|
||||||
|
}
|
||||||
|
return server.url == 'https://api.example.com' &&
|
||||||
|
server.description == 'Production server'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 5: OpenAPITag 结构体
|
||||||
|
fn test_openapi_tag() bool {
|
||||||
|
tag := hono.OpenAPITag{
|
||||||
|
name: 'users'
|
||||||
|
description: 'User operations'
|
||||||
|
external_docs: hono.OpenAPIExternalDocs{
|
||||||
|
url: 'https://docs.example.com/users'
|
||||||
|
description: 'User documentation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tag.name == 'users' &&
|
||||||
|
tag.description == 'User operations'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 6: OpenAPIParameter 结构体
|
||||||
|
fn test_openapi_parameter() bool {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: 'id'
|
||||||
|
in_location: 'path'
|
||||||
|
description: 'User ID'
|
||||||
|
required: true
|
||||||
|
deprecated: false
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return param.name == 'id' &&
|
||||||
|
param.in_location == 'path' &&
|
||||||
|
param.required == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 7: OpenAPIResponse 结构体
|
||||||
|
fn test_openapi_response() bool {
|
||||||
|
response := hono.OpenAPIResponse{
|
||||||
|
description: 'Successful response'
|
||||||
|
content: {
|
||||||
|
'application/json': hono.OpenAPIMediaType{
|
||||||
|
schema: hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.description == 'Successful response' &&
|
||||||
|
'application/json' in response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 8: OpenAPIOperation 结构体
|
||||||
|
fn test_openapi_operation() bool {
|
||||||
|
op := hono.OpenAPIOperation{
|
||||||
|
summary: 'Get user'
|
||||||
|
description: 'Get user by ID'
|
||||||
|
operation_id: 'getUser'
|
||||||
|
tags: ['users']
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op.summary == 'Get user' &&
|
||||||
|
op.operation_id == 'getUser' &&
|
||||||
|
'users' in op.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 9: OpenAPIPathItem 结构体
|
||||||
|
fn test_openapi_path_item() bool {
|
||||||
|
path_item := hono.OpenAPIPathItem{
|
||||||
|
summary: 'User operations'
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'Get user'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post: hono.OpenAPIOperation{
|
||||||
|
summary: 'Create user'
|
||||||
|
responses: {
|
||||||
|
'201': hono.OpenAPIResponse{
|
||||||
|
description: 'Created'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path_item.summary == 'User operations' &&
|
||||||
|
path_item.get.summary == 'Get user' &&
|
||||||
|
path_item.post.summary == 'Create user'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 10: OpenAPISchema 结构体
|
||||||
|
fn test_openapi_schema() bool {
|
||||||
|
schema := hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
title: 'User'
|
||||||
|
description: 'User object'
|
||||||
|
required: ['id', 'name']
|
||||||
|
properties: {
|
||||||
|
'id': hono.OpenAPISchema{
|
||||||
|
schema_type: 'integer'
|
||||||
|
format: 'int64'
|
||||||
|
}
|
||||||
|
'name': hono.OpenAPISchema{
|
||||||
|
schema_type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schema.schema_type == 'object' &&
|
||||||
|
schema.title == 'User' &&
|
||||||
|
'id' in schema.required &&
|
||||||
|
'name' in schema.properties
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 11: OpenAPISecurityScheme 结构体
|
||||||
|
fn test_openapi_security_scheme() bool {
|
||||||
|
scheme := hono.OpenAPISecurityScheme{
|
||||||
|
scheme_type: 'http'
|
||||||
|
description: 'Bearer token authentication'
|
||||||
|
scheme: 'bearer'
|
||||||
|
bearer_format: 'JWT'
|
||||||
|
}
|
||||||
|
return scheme.scheme_type == 'http' &&
|
||||||
|
scheme.scheme == 'bearer' &&
|
||||||
|
scheme.bearer_format == 'JWT'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 12: OpenAPIComponents 结构体
|
||||||
|
fn test_openapi_components() bool {
|
||||||
|
components := hono.OpenAPIComponents{
|
||||||
|
schemas: {
|
||||||
|
'User': hono.OpenAPISchema{
|
||||||
|
schema_type: 'object'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
security_schemes: {
|
||||||
|
'bearerAuth': hono.OpenAPISecurityScheme{
|
||||||
|
scheme_type: 'http'
|
||||||
|
scheme: 'bearer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'User' in components.schemas &&
|
||||||
|
'bearerAuth' in components.security_schemes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 13: OpenAPIDocument 结构体
|
||||||
|
fn test_openapi_document() bool {
|
||||||
|
doc := hono.OpenAPIDocument{
|
||||||
|
openapi: '3.0.0'
|
||||||
|
info: hono.OpenAPIInfo{
|
||||||
|
title: 'My API'
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
servers: [
|
||||||
|
hono.OpenAPIServer{
|
||||||
|
url: 'https://api.example.com'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
paths: {
|
||||||
|
'/users': hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'List users'
|
||||||
|
responses: {
|
||||||
|
'200': hono.OpenAPIResponse{
|
||||||
|
description: 'Success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags: [
|
||||||
|
hono.OpenAPITag{
|
||||||
|
name: 'users'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return doc.openapi == '3.0.0' &&
|
||||||
|
doc.info.title == 'My API' &&
|
||||||
|
'/users' in doc.paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 14: 参数位置支持 (path, query, header, cookie)
|
||||||
|
fn test_parameter_locations() bool {
|
||||||
|
locations := ['path', 'query', 'header', 'cookie']
|
||||||
|
for loc in locations {
|
||||||
|
param := hono.OpenAPIParameter{
|
||||||
|
name: 'test'
|
||||||
|
in_location: loc
|
||||||
|
}
|
||||||
|
if param.in_location != loc {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 15: HTTP 方法支持
|
||||||
|
fn test_http_methods() bool {
|
||||||
|
path_item := hono.OpenAPIPathItem{
|
||||||
|
get: hono.OpenAPIOperation{
|
||||||
|
summary: 'GET'
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
post: hono.OpenAPIOperation{
|
||||||
|
summary: 'POST'
|
||||||
|
responses: {'201': hono.OpenAPIResponse{description: 'Created'}}
|
||||||
|
}
|
||||||
|
put: hono.OpenAPIOperation{
|
||||||
|
summary: 'PUT'
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
delete: hono.OpenAPIOperation{
|
||||||
|
summary: 'DELETE'
|
||||||
|
responses: {'204': hono.OpenAPIResponse{description: 'No Content'}}
|
||||||
|
}
|
||||||
|
patch: hono.OpenAPIOperation{
|
||||||
|
summary: 'PATCH'
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
head: hono.OpenAPIOperation{
|
||||||
|
summary: 'HEAD'
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
options: hono.OpenAPIOperation{
|
||||||
|
summary: 'OPTIONS'
|
||||||
|
responses: {'200': hono.OpenAPIResponse{description: 'OK'}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path_item.get.summary == 'GET' &&
|
||||||
|
path_item.post.summary == 'POST' &&
|
||||||
|
path_item.put.summary == 'PUT' &&
|
||||||
|
path_item.delete.summary == 'DELETE' &&
|
||||||
|
path_item.patch.summary == 'PATCH' &&
|
||||||
|
path_item.head.summary == 'HEAD' &&
|
||||||
|
path_item.options.summary == 'OPTIONS'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🚀 开始 OpenAPI 数据结构测试...\n')
|
||||||
|
|
||||||
|
mut stats := TestStats{}
|
||||||
|
|
||||||
|
// 运行所有测试
|
||||||
|
stats.run_test('OpenAPIContact 结构体', test_openapi_contact)
|
||||||
|
stats.run_test('OpenAPILicense 结构体', test_openapi_license)
|
||||||
|
stats.run_test('OpenAPIInfo 结构体', test_openapi_info)
|
||||||
|
stats.run_test('OpenAPIServer 结构体', test_openapi_server)
|
||||||
|
stats.run_test('OpenAPITag 结构体', test_openapi_tag)
|
||||||
|
stats.run_test('OpenAPIParameter 结构体', test_openapi_parameter)
|
||||||
|
stats.run_test('OpenAPIResponse 结构体', test_openapi_response)
|
||||||
|
stats.run_test('OpenAPIOperation 结构体', test_openapi_operation)
|
||||||
|
stats.run_test('OpenAPIPathItem 结构体', test_openapi_path_item)
|
||||||
|
stats.run_test('OpenAPISchema 结构体', test_openapi_schema)
|
||||||
|
stats.run_test('OpenAPISecurityScheme 结构体', test_openapi_security_scheme)
|
||||||
|
stats.run_test('OpenAPIComponents 结构体', test_openapi_components)
|
||||||
|
stats.run_test('OpenAPIDocument 结构体', test_openapi_document)
|
||||||
|
stats.run_test('参数位置支持', test_parameter_locations)
|
||||||
|
stats.run_test('HTTP 方法支持', test_http_methods)
|
||||||
|
|
||||||
|
// 打印测试总结
|
||||||
|
stats.print_summary()
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
// test_route_extraction_property.v - Property test for route extraction completeness
|
||||||
|
// **Feature: swagger-ui, Property 6: Route Extraction Completeness**
|
||||||
|
// **Validates: Requirements 5.1, 5.2, 5.3**
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import net.http
|
||||||
|
import rand
|
||||||
|
|
||||||
|
// Test data generators
|
||||||
|
fn generate_random_path() string {
|
||||||
|
paths := [
|
||||||
|
'/',
|
||||||
|
'/users',
|
||||||
|
'/users/:id',
|
||||||
|
'/api/v1/items',
|
||||||
|
'/api/v1/items/:itemId',
|
||||||
|
'/posts/:postId/comments/:commentId',
|
||||||
|
'/products/:productId/reviews',
|
||||||
|
'/admin/settings',
|
||||||
|
'/health',
|
||||||
|
'/api/users/:userId/orders/:orderId/items',
|
||||||
|
]
|
||||||
|
return paths[rand.intn(paths.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_random_method() string {
|
||||||
|
methods := ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
|
||||||
|
return methods[rand.intn(methods.len) or { 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple handler for testing
|
||||||
|
fn test_handler(mut c hono.Context) http.Response {
|
||||||
|
return c.text('OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property 6: Route Extraction Completeness
|
||||||
|
// *For any* Hono application with registered routes (including sub-applications),
|
||||||
|
// calling get_routes() SHALL return all routes with correct paths, methods, and path parameters.
|
||||||
|
fn test_property_route_extraction_completeness() {
|
||||||
|
println('Property 6: Route Extraction Completeness')
|
||||||
|
println('Testing that get_routes() returns all registered routes with correct information...')
|
||||||
|
|
||||||
|
// Test 1: Basic routes extraction
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// Register routes with different methods
|
||||||
|
app.get('/users', test_handler)
|
||||||
|
app.post('/users', test_handler)
|
||||||
|
app.get('/users/:id', test_handler)
|
||||||
|
app.put('/users/:id', test_handler)
|
||||||
|
app.delete('/users/:id', test_handler)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
// Verify all routes are extracted
|
||||||
|
assert routes.len == 5, 'Expected 5 routes, got ${routes.len}'
|
||||||
|
|
||||||
|
// Verify methods are correct
|
||||||
|
mut methods := map[string]int{}
|
||||||
|
for route in routes {
|
||||||
|
methods[route.method]++
|
||||||
|
}
|
||||||
|
assert methods['GET'] == 2, 'Expected 2 GET routes'
|
||||||
|
assert methods['POST'] == 1, 'Expected 1 POST route'
|
||||||
|
assert methods['PUT'] == 1, 'Expected 1 PUT route'
|
||||||
|
assert methods['DELETE'] == 1, 'Expected 1 DELETE route'
|
||||||
|
|
||||||
|
println(' ✅ Basic routes extraction: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Path parameters extraction
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/users/:userId', test_handler)
|
||||||
|
app.get('/posts/:postId/comments/:commentId', test_handler)
|
||||||
|
app.get('/api/v1/items/:itemId/reviews/:reviewId', test_handler)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
// Find routes and verify path parameters
|
||||||
|
for route in routes {
|
||||||
|
if route.path == '/users/:userId' {
|
||||||
|
assert route.path_params.len == 1, 'Expected 1 path param for /users/:userId'
|
||||||
|
assert 'userId' in route.path_params, 'Expected userId in path params'
|
||||||
|
} else if route.path == '/posts/:postId/comments/:commentId' {
|
||||||
|
assert route.path_params.len == 2, 'Expected 2 path params for /posts/:postId/comments/:commentId'
|
||||||
|
assert 'postId' in route.path_params, 'Expected postId in path params'
|
||||||
|
assert 'commentId' in route.path_params, 'Expected commentId in path params'
|
||||||
|
} else if route.path == '/api/v1/items/:itemId/reviews/:reviewId' {
|
||||||
|
assert route.path_params.len == 2, 'Expected 2 path params'
|
||||||
|
assert 'itemId' in route.path_params, 'Expected itemId in path params'
|
||||||
|
assert 'reviewId' in route.path_params, 'Expected reviewId in path params'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println(' ✅ Path parameters extraction: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: All HTTP methods support
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/test', test_handler)
|
||||||
|
app.post('/test', test_handler)
|
||||||
|
app.put('/test', test_handler)
|
||||||
|
app.delete('/test', test_handler)
|
||||||
|
app.patch('/test', test_handler)
|
||||||
|
app.head('/test', test_handler)
|
||||||
|
app.options('/test', test_handler)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
// Verify all 7 HTTP methods are present
|
||||||
|
mut found_methods := map[string]bool{}
|
||||||
|
for route in routes {
|
||||||
|
found_methods[route.method] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert found_methods['GET'], 'GET method not found'
|
||||||
|
assert found_methods['POST'], 'POST method not found'
|
||||||
|
assert found_methods['PUT'], 'PUT method not found'
|
||||||
|
assert found_methods['DELETE'], 'DELETE method not found'
|
||||||
|
assert found_methods['PATCH'], 'PATCH method not found'
|
||||||
|
assert found_methods['HEAD'], 'HEAD method not found'
|
||||||
|
assert found_methods['OPTIONS'], 'OPTIONS method not found'
|
||||||
|
|
||||||
|
println(' ✅ All HTTP methods support: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Sub-application routes extraction
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
mut subapp := hono.Hono.new()
|
||||||
|
|
||||||
|
// Register routes in main app
|
||||||
|
app.get('/main', test_handler)
|
||||||
|
|
||||||
|
// Register routes in sub-app
|
||||||
|
subapp.get('/items', test_handler)
|
||||||
|
subapp.get('/items/:id', test_handler)
|
||||||
|
subapp.post('/items', test_handler)
|
||||||
|
|
||||||
|
// Mount sub-app
|
||||||
|
app.route('/api', mut subapp)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
// Verify main app route
|
||||||
|
mut found_main := false
|
||||||
|
for route in routes {
|
||||||
|
if route.path == '/main' && route.method == 'GET' {
|
||||||
|
found_main = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert found_main, 'Main app route /main not found'
|
||||||
|
|
||||||
|
// Verify sub-app routes are included with prefix
|
||||||
|
mut found_api_items := false
|
||||||
|
mut found_api_items_id := false
|
||||||
|
mut found_api_items_post := false
|
||||||
|
|
||||||
|
for route in routes {
|
||||||
|
if route.path == '/api/items' && route.method == 'GET' {
|
||||||
|
found_api_items = true
|
||||||
|
}
|
||||||
|
if route.path == '/api/items/:id' && route.method == 'GET' {
|
||||||
|
found_api_items_id = true
|
||||||
|
// Verify path params are extracted correctly
|
||||||
|
assert 'id' in route.path_params, 'Expected id in path params for /api/items/:id'
|
||||||
|
}
|
||||||
|
if route.path == '/api/items' && route.method == 'POST' {
|
||||||
|
found_api_items_post = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert found_api_items, 'Sub-app route /api/items GET not found'
|
||||||
|
assert found_api_items_id, 'Sub-app route /api/items/:id GET not found'
|
||||||
|
assert found_api_items_post, 'Sub-app route /api/items POST not found'
|
||||||
|
|
||||||
|
println(' ✅ Sub-application routes extraction: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Empty path parameters for static routes
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/static/path', test_handler)
|
||||||
|
app.get('/another/static', test_handler)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
for route in routes {
|
||||||
|
assert route.path_params.len == 0, 'Static route should have no path params'
|
||||||
|
}
|
||||||
|
|
||||||
|
println(' ✅ Static routes have no path params: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Route handler is preserved
|
||||||
|
{
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/test', test_handler)
|
||||||
|
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
assert routes.len == 1, 'Expected 1 route'
|
||||||
|
// Handler should not be nil (we can't directly compare functions, but we can check it exists)
|
||||||
|
// The handler field is of type IHandler which is an interface
|
||||||
|
|
||||||
|
println(' ✅ Route handler preservation: PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
println('\n✅ Property 6: Route Extraction Completeness - ALL TESTS PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected route structure for testing
|
||||||
|
struct ExpectedRoute {
|
||||||
|
path string
|
||||||
|
method string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test with randomized inputs
|
||||||
|
fn test_property_route_extraction_randomized() {
|
||||||
|
println('\nProperty 6 (Randomized): Route Extraction with Random Inputs')
|
||||||
|
|
||||||
|
iterations := 100
|
||||||
|
mut passed := 0
|
||||||
|
|
||||||
|
for i in 0 .. iterations {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// Generate random number of routes (1-10)
|
||||||
|
num_routes := rand.intn(10) or { 1 } + 1
|
||||||
|
mut expected_routes := []ExpectedRoute{}
|
||||||
|
|
||||||
|
for _ in 0 .. num_routes {
|
||||||
|
path := generate_random_path()
|
||||||
|
method := generate_random_method()
|
||||||
|
|
||||||
|
// Register route based on method
|
||||||
|
match method {
|
||||||
|
'GET' { app.get(path, test_handler) }
|
||||||
|
'POST' { app.post(path, test_handler) }
|
||||||
|
'PUT' { app.put(path, test_handler) }
|
||||||
|
'DELETE' { app.delete(path, test_handler) }
|
||||||
|
'PATCH' { app.patch(path, test_handler) }
|
||||||
|
'HEAD' { app.head(path, test_handler) }
|
||||||
|
'OPTIONS' { app.options(path, test_handler) }
|
||||||
|
else {}
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_routes << ExpectedRoute{
|
||||||
|
path: path
|
||||||
|
method: method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get routes and verify
|
||||||
|
routes := app.get_routes()
|
||||||
|
|
||||||
|
// Verify count matches
|
||||||
|
if routes.len != expected_routes.len {
|
||||||
|
println(' ❌ Iteration ${i}: Expected ${expected_routes.len} routes, got ${routes.len}')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected routes are present
|
||||||
|
mut all_found := true
|
||||||
|
for expected in expected_routes {
|
||||||
|
mut found := false
|
||||||
|
for route in routes {
|
||||||
|
if route.path == expected.path && route.method == expected.method {
|
||||||
|
found = true
|
||||||
|
|
||||||
|
// Verify path params are correctly extracted
|
||||||
|
expected_params := extract_expected_params(expected.path)
|
||||||
|
if route.path_params.len != expected_params.len {
|
||||||
|
all_found = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for param in expected_params {
|
||||||
|
if param !in route.path_params {
|
||||||
|
all_found = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
all_found = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_found {
|
||||||
|
passed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success_rate := f64(passed) / f64(iterations) * 100.0
|
||||||
|
println(' Passed: ${passed}/${iterations} (${success_rate:.1}%)')
|
||||||
|
|
||||||
|
// Allow some tolerance for edge cases
|
||||||
|
assert passed >= iterations * 95 / 100, 'Property test failed: less than 95% success rate'
|
||||||
|
|
||||||
|
println('✅ Property 6 (Randomized): PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract expected path parameters from a path
|
||||||
|
fn extract_expected_params(path string) []string {
|
||||||
|
mut params := []string{}
|
||||||
|
segments := path.split('/')
|
||||||
|
for segment in segments {
|
||||||
|
if segment.starts_with(':') && segment.len > 1 {
|
||||||
|
params << segment[1..]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('=' .repeat(70))
|
||||||
|
println('Property-Based Test: Route Extraction Completeness')
|
||||||
|
println('=' .repeat(70))
|
||||||
|
|
||||||
|
test_property_route_extraction_completeness()
|
||||||
|
test_property_route_extraction_randomized()
|
||||||
|
|
||||||
|
println('\n' + '=' .repeat(70))
|
||||||
|
println('All Property Tests Completed Successfully!')
|
||||||
|
println('=' .repeat(70))
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
// test_swagger_ui_options_property.v - Property-Based Test for Swagger UI Options
|
||||||
|
// **Feature: swagger-ui, Property 2: Swagger UI Options Appear in Generated HTML**
|
||||||
|
// **Validates: Requirements 1.5, 4.2, 4.3, 4.5**
|
||||||
|
//
|
||||||
|
// Property: For any SwaggerUIOptions configuration, all non-default option values
|
||||||
|
// SHALL appear in the generated HTML output in the appropriate locations
|
||||||
|
// (title in <title> tag, url in SwaggerUIBundle config, etc.).
|
||||||
|
module main
|
||||||
|
|
||||||
|
import hono
|
||||||
|
import rand
|
||||||
|
import time
|
||||||
|
|
||||||
|
const property_test_iterations = 100
|
||||||
|
|
||||||
|
struct PropertyTestStats {
|
||||||
|
mut:
|
||||||
|
total_tests int
|
||||||
|
passed_tests int
|
||||||
|
failed_tests int
|
||||||
|
failed_examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_pass() {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.passed_tests++
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (mut stats PropertyTestStats) record_fail(example string) {
|
||||||
|
stats.total_tests++
|
||||||
|
stats.failed_tests++
|
||||||
|
stats.failed_examples << example
|
||||||
|
}
|
||||||
|
|
||||||
|
fn (stats PropertyTestStats) print_summary() {
|
||||||
|
println('\n=== Property Test Summary ===')
|
||||||
|
println('Total iterations: ${stats.total_tests}')
|
||||||
|
println('Passed: ${stats.passed_tests}')
|
||||||
|
println('Failed: ${stats.failed_tests}')
|
||||||
|
|
||||||
|
if stats.failed_tests == 0 {
|
||||||
|
println('🎉 Property test PASSED!')
|
||||||
|
} else {
|
||||||
|
println('❌ Property test FAILED!')
|
||||||
|
println('\nFailing examples:')
|
||||||
|
for i, example in stats.failed_examples {
|
||||||
|
if i >= 3 {
|
||||||
|
println('... and ${stats.failed_examples.len - 3} more')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
println(' ${example}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random string generator for safe HTML content
|
||||||
|
fn random_string(min_len int, max_len int) string {
|
||||||
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||||
|
mut result := ''
|
||||||
|
for _ in 0 .. len {
|
||||||
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||||
|
result += chars[idx..idx + 1]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random URL path generator
|
||||||
|
fn random_url_path() string {
|
||||||
|
return '/' + random_string(3, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random doc expansion value
|
||||||
|
fn random_doc_expansion() string {
|
||||||
|
values := ['list', 'full', 'none']
|
||||||
|
idx := rand.int_in_range(0, values.len) or { 0 }
|
||||||
|
return values[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random integer in range
|
||||||
|
fn random_int(min int, max int) int {
|
||||||
|
return rand.int_in_range(min, max + 1) or { min }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random SwaggerUIOptions
|
||||||
|
fn generate_random_options() hono.SwaggerUIOptions {
|
||||||
|
return hono.SwaggerUIOptions{
|
||||||
|
url: random_url_path()
|
||||||
|
title: random_string(5, 20)
|
||||||
|
deep_linking: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
display_request_duration: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
default_models_expand_depth: random_int(-1, 5)
|
||||||
|
doc_expansion: random_doc_expansion()
|
||||||
|
filter: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
show_extensions: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
show_common_extensions: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
try_it_out_enabled: rand.int_in_range(0, 2) or { 0 } == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Title appears in HTML <title> tag
|
||||||
|
fn test_title_in_html(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected := '<title>${options.title}</title>'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: URL appears in SwaggerUIBundle config
|
||||||
|
fn test_url_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected := 'url: "${options.url}"'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: deepLinking option appears correctly
|
||||||
|
fn test_deep_linking_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.deep_linking { 'true' } else { 'false' }
|
||||||
|
expected := 'deepLinking: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: displayRequestDuration option appears correctly
|
||||||
|
fn test_display_request_duration_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.display_request_duration { 'true' } else { 'false' }
|
||||||
|
expected := 'displayRequestDuration: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: defaultModelsExpandDepth option appears correctly
|
||||||
|
fn test_default_models_expand_depth_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected := 'defaultModelsExpandDepth: ${options.default_models_expand_depth}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: docExpansion option appears correctly
|
||||||
|
fn test_doc_expansion_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected := 'docExpansion: "${options.doc_expansion}"'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: filter option appears correctly
|
||||||
|
fn test_filter_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.filter { 'true' } else { 'false' }
|
||||||
|
expected := 'filter: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: showExtensions option appears correctly
|
||||||
|
fn test_show_extensions_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.show_extensions { 'true' } else { 'false' }
|
||||||
|
expected := 'showExtensions: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: showCommonExtensions option appears correctly
|
||||||
|
fn test_show_common_extensions_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.show_common_extensions { 'true' } else { 'false' }
|
||||||
|
expected := 'showCommonExtensions: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: tryItOutEnabled option appears correctly
|
||||||
|
fn test_try_it_out_enabled_in_config(options hono.SwaggerUIOptions, html string) bool {
|
||||||
|
expected_value := if options.try_it_out_enabled { 'true' } else { 'false' }
|
||||||
|
expected := 'tryItOutEnabled: ${expected_value}'
|
||||||
|
return html.contains(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: HTML is valid HTML5 document
|
||||||
|
fn test_valid_html5_document(html string) bool {
|
||||||
|
has_doctype := html.contains('<!DOCTYPE html>')
|
||||||
|
has_html_tag := html.contains('<html') && html.contains('</html>')
|
||||||
|
has_head := html.contains('<head>') && html.contains('</head>')
|
||||||
|
has_body := html.contains('<body>') && html.contains('</body>')
|
||||||
|
has_charset := html.contains('charset="UTF-8"') || html.contains("charset='UTF-8'")
|
||||||
|
return has_doctype && has_html_tag && has_head && has_body && has_charset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Swagger UI CDN resources are included
|
||||||
|
fn test_cdn_resources_included(html string) bool {
|
||||||
|
has_css := html.contains('https://unpkg.com/swagger-ui-dist@5/swagger-ui.css')
|
||||||
|
has_bundle_js := html.contains('https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js')
|
||||||
|
has_standalone_js := html.contains('https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js')
|
||||||
|
return has_css && has_bundle_js && has_standalone_js
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Custom CSS URL appears when provided
|
||||||
|
fn test_custom_css_url_included() bool {
|
||||||
|
custom_url := 'https://example.com/custom.css'
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
custom_css_url: custom_url
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
html := response.body
|
||||||
|
|
||||||
|
return html.contains('href="${custom_url}"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Custom JS URL appears when provided
|
||||||
|
fn test_custom_js_url_included() bool {
|
||||||
|
custom_url := 'https://example.com/custom.js'
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
custom_js_url: custom_url
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
html := response.body
|
||||||
|
|
||||||
|
return html.contains('src="${custom_url}"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Custom CSS inline appears when provided
|
||||||
|
fn test_custom_css_inline_included() bool {
|
||||||
|
custom_css := '.swagger-ui { background: red; }'
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
custom_css: custom_css
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
html := response.body
|
||||||
|
|
||||||
|
return html.contains(custom_css)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Custom JS inline appears when provided
|
||||||
|
fn test_custom_js_inline_included() bool {
|
||||||
|
custom_js := 'console.log("Custom JS loaded");'
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
custom_js: custom_js
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
html := response.body
|
||||||
|
|
||||||
|
return html.contains(custom_js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Response has correct Content-Type
|
||||||
|
fn test_response_content_type() bool {
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
|
||||||
|
content_type := response.header.get(.content_type) or { '' }
|
||||||
|
return content_type.contains('text/html')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Response status code is 200
|
||||||
|
fn test_response_status_code() bool {
|
||||||
|
options := hono.SwaggerUIOptions{
|
||||||
|
url: '/doc'
|
||||||
|
title: 'Test API'
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property test: Swagger UI Options appear in generated HTML
|
||||||
|
fn test_swagger_ui_options_property() PropertyTestStats {
|
||||||
|
mut stats := PropertyTestStats{}
|
||||||
|
|
||||||
|
println('Running Property 2: Swagger UI Options Appear in Generated HTML')
|
||||||
|
println('Iterations: ${property_test_iterations}')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
for i in 0 .. property_test_iterations {
|
||||||
|
// Generate random options
|
||||||
|
options := generate_random_options()
|
||||||
|
|
||||||
|
// Generate HTML using the swagger_ui handler
|
||||||
|
handler := hono.swagger_ui(options)
|
||||||
|
mut ctx := hono.Context{}
|
||||||
|
response := handler(mut ctx)
|
||||||
|
html := response.body
|
||||||
|
|
||||||
|
// Test 1: Title in HTML
|
||||||
|
if test_title_in_html(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Title "${options.title}" not found in HTML')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: URL in config
|
||||||
|
if test_url_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: URL "${options.url}" not found in config')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: deepLinking
|
||||||
|
if test_deep_linking_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: deepLinking=${options.deep_linking} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: displayRequestDuration
|
||||||
|
if test_display_request_duration_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: displayRequestDuration=${options.display_request_duration} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: defaultModelsExpandDepth
|
||||||
|
if test_default_models_expand_depth_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: defaultModelsExpandDepth=${options.default_models_expand_depth} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: docExpansion
|
||||||
|
if test_doc_expansion_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: docExpansion="${options.doc_expansion}" not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: filter
|
||||||
|
if test_filter_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: filter=${options.filter} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: showExtensions
|
||||||
|
if test_show_extensions_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: showExtensions=${options.show_extensions} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: showCommonExtensions
|
||||||
|
if test_show_common_extensions_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: showCommonExtensions=${options.show_common_extensions} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: tryItOutEnabled
|
||||||
|
if test_try_it_out_enabled_in_config(options, html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: tryItOutEnabled=${options.try_it_out_enabled} not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: Valid HTML5 document
|
||||||
|
if test_valid_html5_document(html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: Generated HTML is not a valid HTML5 document')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12: CDN resources included
|
||||||
|
if test_cdn_resources_included(html) {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Iteration ${i}: CDN resources not included')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
if (i + 1) % 20 == 0 {
|
||||||
|
print('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Additional specific tests (run once)
|
||||||
|
println('\nRunning additional specific tests...')
|
||||||
|
|
||||||
|
// Test custom CSS URL
|
||||||
|
if test_custom_css_url_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Custom CSS URL not included when provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test custom JS URL
|
||||||
|
if test_custom_js_url_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Custom JS URL not included when provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test custom CSS inline
|
||||||
|
if test_custom_css_inline_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Custom CSS inline not included when provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test custom JS inline
|
||||||
|
if test_custom_js_inline_included() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Custom JS inline not included when provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test response Content-Type
|
||||||
|
if test_response_content_type() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Response Content-Type is not text/html')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test response status code
|
||||||
|
if test_response_status_code() {
|
||||||
|
stats.record_pass()
|
||||||
|
} else {
|
||||||
|
stats.record_fail('Response status code is not 200')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println('🧪 Property-Based Test: Swagger UI Options Appear in Generated HTML')
|
||||||
|
println('====================================================================')
|
||||||
|
println('')
|
||||||
|
|
||||||
|
// Seed random number generator
|
||||||
|
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
|
||||||
|
|
||||||
|
stats := test_swagger_ui_options_property()
|
||||||
|
stats.print_summary()
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
if stats.failed_tests > 0 {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-18
@@ -58,27 +58,28 @@ fn main() {
|
|||||||
## Build Command
|
## Build Command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
v -enable-globals -cc gcc -ldflags "-ldbghelp" your_app.v -o app.exe
|
# macOS / Linux
|
||||||
|
v -enable-globals -prod -o app your_app.v
|
||||||
|
./app
|
||||||
|
|
||||||
|
# Windows (must use gcc, tcc does not support .a static library format)
|
||||||
|
v -enable-globals -cc gcc -ldflags "-ldbghelp" -o app.exe your_app.v
|
||||||
|
.\app.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
**Required flags:**
|
**Required flags:**
|
||||||
- `-enable-globals` - Required for uSockets global state
|
- `-enable-globals` - Required for uSockets global state
|
||||||
- `-cc gcc` - Use GCC compiler (MinGW-w64 on Windows)
|
- `-cc gcc` - Use GCC compiler (MinGW-w64 on Windows, required because V's default compiler tcc does not support MinGW `.a` static library format)
|
||||||
- `-ldflags "-ldbghelp"` - Fix Windows linking (Windows only)
|
- `-ldflags "-ldbghelp"` - Required on Windows for libuv linking
|
||||||
|
|
||||||
## Prerequisites (Windows)
|
## Pre-compiled Libraries
|
||||||
|
|
||||||
1. **GCC (MinGW-w64)**
|
The uSockets library and libuv are pre-compiled and included in the `lib/{platform}/` directory:
|
||||||
```powershell
|
|
||||||
scoop install mingw
|
|
||||||
# or
|
|
||||||
choco install mingw
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **V Compiler** (0.4.x or later)
|
- `lib/windows/` - Windows x64
|
||||||
```powershell
|
- `lib/linux/` - Linux x64
|
||||||
scoop install vlang
|
- `lib/macos-arm64/` - macOS Apple Silicon
|
||||||
```
|
- `lib/macos-x64/` - macOS Intel
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
@@ -89,10 +90,15 @@ usockets/
|
|||||||
│ ├── uv.h # libuv API
|
│ ├── uv.h # libuv API
|
||||||
│ └── uv/ # libuv headers
|
│ └── uv/ # libuv headers
|
||||||
├── lib/ # Pre-compiled libraries
|
├── lib/ # Pre-compiled libraries
|
||||||
│ ├── libusockets_full.a # uSockets + libuv (main)
|
│ ├── windows/ # Windows x64
|
||||||
│ ├── libuv.a # libuv static lib
|
│ ├── linux/ # Linux x64
|
||||||
│ └── ...
|
│ ├── macos-arm64/ # macOS Apple Silicon
|
||||||
└── usockets.v # V bindings
|
│ └── macos-x64/ # macOS Intel
|
||||||
|
├── src/ # Modified source files
|
||||||
|
│ └── bsd.c # Modified with backlog=16384
|
||||||
|
├── build.sh # Build script
|
||||||
|
├── usockets.v # V bindings
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -107,6 +113,104 @@ app.listen_usockets_with_config(meiseayoung.hono.UsocketsConfig{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Building uSockets Library from Source
|
||||||
|
|
||||||
|
If you need to rebuild the uSockets library (e.g., for a different platform or custom modifications), use the provided build script.
|
||||||
|
|
||||||
|
### Build Prerequisites
|
||||||
|
|
||||||
|
- **Git** - For cloning source repositories
|
||||||
|
- **GCC** - C compiler
|
||||||
|
- **CMake** - For building libuv
|
||||||
|
- **ar** - Archive tool (included with GCC)
|
||||||
|
|
||||||
|
#### Installing Prerequisites
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
brew install cmake
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Ubuntu/Debian):**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install build-essential cmake git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (CentOS/RHEL):**
|
||||||
|
```bash
|
||||||
|
sudo yum groupinstall "Development Tools"
|
||||||
|
sudo yum install cmake git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (Git Bash/MSYS2):**
|
||||||
|
```powershell
|
||||||
|
# Install via Scoop
|
||||||
|
scoop install mingw cmake git
|
||||||
|
|
||||||
|
# Or via Chocolatey
|
||||||
|
choco install mingw cmake git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Build Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd usockets
|
||||||
|
chmod +x build.sh
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Clone uSockets and libuv repositories to `/tmp/`
|
||||||
|
2. Apply the backlog=16384 modification for high concurrency
|
||||||
|
3. Compile libuv as a static library
|
||||||
|
4. Compile uSockets with libuv support
|
||||||
|
5. Merge both libraries into `libusockets_full.a`
|
||||||
|
6. Output to the appropriate `lib/{platform}/` directory
|
||||||
|
|
||||||
|
### Build Script Details
|
||||||
|
|
||||||
|
The build script (`build.sh`) automatically:
|
||||||
|
- Detects your operating system (Windows/macOS/Linux)
|
||||||
|
- Detects CPU architecture (x64/arm64 on macOS)
|
||||||
|
- Applies the backlog modification from `src/bsd.c`
|
||||||
|
- Compiles with optimizations (`-O3`)
|
||||||
|
- Creates a merged static library containing both uSockets and libuv
|
||||||
|
|
||||||
|
### Custom Modifications
|
||||||
|
|
||||||
|
To customize the build:
|
||||||
|
|
||||||
|
1. **Change backlog size**: Edit `src/bsd.c` and modify the `listen()` call
|
||||||
|
2. **Enable SSL**: Remove `-DLIBUS_NO_SSL` from the build script and link OpenSSL
|
||||||
|
3. **Use different event loop**: Modify `-DLIBUS_USE_LIBUV` flag
|
||||||
|
|
||||||
|
### Troubleshooting Build Issues
|
||||||
|
|
||||||
|
**CMake not found:**
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install cmake
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
sudo apt install cmake # or sudo yum install cmake
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
scoop install cmake
|
||||||
|
```
|
||||||
|
|
||||||
|
**GCC not found (Windows):**
|
||||||
|
```powershell
|
||||||
|
scoop install mingw
|
||||||
|
# Make sure MinGW bin directory is in PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permission denied:**
|
||||||
|
```bash
|
||||||
|
chmod +x build.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- On Windows, uSockets and picoev have similar performance
|
- On Windows, uSockets and picoev have similar performance
|
||||||
|
|||||||
+93
-26
@@ -1,13 +1,36 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# uSockets 编译脚本
|
# uSockets 编译脚本
|
||||||
# 用于重新编译 libusockets_full.a
|
# 用于重新编译 libusockets_full.a (包含 uSockets + libuv)
|
||||||
|
# 支持: Windows (Git Bash/MSYS2), macOS (Intel/Apple Silicon), Linux
|
||||||
|
# 输出到: usockets/lib/{platform}/libusockets_full.a
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
USOCKETS_DIR="/tmp/uSockets"
|
USOCKETS_DIR="/tmp/uSockets"
|
||||||
|
LIBUV_DIR="/tmp/libuv"
|
||||||
|
MERGE_DIR="/tmp/merge_libs"
|
||||||
|
|
||||||
echo "=== uSockets 编译脚本 ==="
|
echo "=== uSockets 编译脚本 ==="
|
||||||
|
echo "检测到系统: $OSTYPE"
|
||||||
|
|
||||||
|
# 确定输出目录
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
if [[ $(uname -m) == "arm64" ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-arm64"
|
||||||
|
else
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-x64"
|
||||||
|
fi
|
||||||
|
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/windows"
|
||||||
|
elif [[ "$OSTYPE" == "linux"* ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
|
||||||
|
else
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "输出目录: $OUTPUT_DIR"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
# 检查是否有 uSockets 源码
|
# 检查是否有 uSockets 源码
|
||||||
if [ ! -d "$USOCKETS_DIR" ]; then
|
if [ ! -d "$USOCKETS_DIR" ]; then
|
||||||
@@ -15,41 +38,85 @@ if [ ! -d "$USOCKETS_DIR" ]; then
|
|||||||
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
|
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 检查是否有 libuv 源码
|
||||||
|
if [ ! -d "$LIBUV_DIR" ]; then
|
||||||
|
echo "克隆 libuv..."
|
||||||
|
git clone --depth 1 --branch v1.48.0 https://github.com/libuv/libuv.git "$LIBUV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# 应用 backlog 修改
|
# 应用 backlog 修改
|
||||||
echo "应用 backlog=16384 修改..."
|
echo "应用 backlog=16384 修改..."
|
||||||
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
|
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
|
||||||
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
|
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
|
||||||
else
|
else
|
||||||
# 如果没有本地修改的源码,直接修改
|
|
||||||
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
|
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 编译
|
# 编译 libuv
|
||||||
echo "编译 uSockets..."
|
compile_libuv() {
|
||||||
cd "$USOCKETS_DIR"
|
echo "编译 libuv..."
|
||||||
make clean
|
cd "$LIBUV_DIR"
|
||||||
|
rm -rf build 2>/dev/null || true
|
||||||
|
mkdir -p build
|
||||||
|
cd build
|
||||||
|
|
||||||
# 根据系统选择编译参数
|
if ! command -v cmake &> /dev/null; then
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
echo "错误: 需要安装 cmake"
|
||||||
# macOS
|
echo " macOS: brew install cmake"
|
||||||
if [[ $(uname -m) == "arm64" ]]; then
|
echo " Linux: sudo apt install cmake 或 sudo yum install cmake"
|
||||||
# Apple Silicon
|
echo " Windows: scoop install cmake 或 choco install cmake"
|
||||||
WITH_LIBUV=1 CFLAGS="-I/opt/homebrew/include" make
|
exit 1
|
||||||
else
|
|
||||||
# Intel Mac
|
|
||||||
WITH_LIBUV=1 CFLAGS="-I/usr/local/include" make
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
# Linux
|
|
||||||
WITH_LIBUV=1 make
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 复制到 v-hono
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
echo "复制库文件..."
|
cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
|
||||||
cp "$USOCKETS_DIR/uSockets.a" "$SCRIPT_DIR/../lib/libusockets_full.a"
|
else
|
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== 编译完成 ==="
|
cmake --build . --config Release
|
||||||
echo "库文件: $SCRIPT_DIR/../lib/libusockets_full.a"
|
}
|
||||||
|
|
||||||
|
# 编译 uSockets
|
||||||
|
compile_usockets() {
|
||||||
|
echo "编译 uSockets..."
|
||||||
|
cd "$USOCKETS_DIR"
|
||||||
|
rm -f *.o *.a 2>/dev/null || true
|
||||||
|
|
||||||
|
gcc -DLIBUS_NO_SSL -DLIBUS_USE_LIBUV -std=c11 -Isrc -I"$LIBUV_DIR/include" -O3 -c src/*.c src/eventing/*.c src/crypto/*.c
|
||||||
|
ar rcs uSockets.a *.o
|
||||||
|
rm -f *.o
|
||||||
|
}
|
||||||
|
|
||||||
|
# 合并库文件
|
||||||
|
merge_libraries() {
|
||||||
|
echo "合并库文件..."
|
||||||
|
rm -rf "$MERGE_DIR"
|
||||||
|
mkdir -p "$MERGE_DIR"
|
||||||
|
|
||||||
|
cd "$MERGE_DIR"
|
||||||
|
ar x "$USOCKETS_DIR/uSockets.a"
|
||||||
|
ar x "$LIBUV_DIR/build/libuv.a"
|
||||||
|
|
||||||
|
ar rcs "$OUTPUT_DIR/libusockets_full.a" *.o
|
||||||
|
|
||||||
|
rm -rf "$MERGE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 执行编译
|
||||||
|
compile_libuv
|
||||||
|
compile_usockets
|
||||||
|
merge_libraries
|
||||||
|
|
||||||
|
# 显示结果
|
||||||
|
LIB_SIZE=$(ls -lh "$OUTPUT_DIR/libusockets_full.a" | awk '{print $5}')
|
||||||
echo ""
|
echo ""
|
||||||
echo "重新编译 v-hono 应用:"
|
echo "=== 编译完成 ==="
|
||||||
echo " v -enable-globals -prod -o your_app your_app.v"
|
echo "库文件: $OUTPUT_DIR/libusockets_full.a ($LIB_SIZE)"
|
||||||
|
echo ""
|
||||||
|
echo "编译 v-hono 应用:"
|
||||||
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
|
echo " v -enable-globals -cc gcc -ldflags \"-ldbghelp\" -o app.exe app.v"
|
||||||
|
else
|
||||||
|
echo " v -enable-globals -prod -o app app.v"
|
||||||
|
fi
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
-23
@@ -2,41 +2,36 @@ module usockets
|
|||||||
|
|
||||||
// 条件编译:根据操作系统选择正确的路径和库
|
// 条件编译:根据操作系统选择正确的路径和库
|
||||||
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
|
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
|
||||||
|
// 预编译库按平台存放在 usockets/lib/{platform}/ 目录
|
||||||
|
|
||||||
$if windows {
|
$if windows {
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/usockets/lib
|
#flag -L@VMODROOT/usockets/lib/windows
|
||||||
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/windows/libusockets_full.a
|
||||||
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
|
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32 -ldbghelp
|
||||||
} $else $if macos {
|
} $else $if macos {
|
||||||
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
|
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
|
|
||||||
// libuv 路径 - 根据架构选择 Homebrew 路径
|
|
||||||
$if arm64 {
|
|
||||||
#flag -I/opt/homebrew/include
|
|
||||||
#flag -L/opt/homebrew/lib
|
|
||||||
} $else {
|
|
||||||
#flag -I/usr/local/include
|
|
||||||
#flag -L/usr/local/lib
|
|
||||||
}
|
|
||||||
|
|
||||||
// uSockets 库路径
|
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/usockets/lib
|
|
||||||
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
$if arm64 {
|
||||||
#flag -luv
|
// Apple Silicon (M1/M2/M3)
|
||||||
|
#flag -L@VMODROOT/usockets/lib/macos-arm64
|
||||||
|
#flag @VMODROOT/usockets/lib/macos-arm64/libusockets_full.a
|
||||||
|
} $else {
|
||||||
|
// Intel Mac
|
||||||
|
#flag -L@VMODROOT/usockets/lib/macos-x64
|
||||||
|
#flag @VMODROOT/usockets/lib/macos-x64/libusockets_full.a
|
||||||
|
}
|
||||||
} $else {
|
} $else {
|
||||||
// Linux 和其他平台
|
// Linux 和其他平台
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/usockets/lib
|
#flag -L@VMODROOT/usockets/lib/linux
|
||||||
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/linux/libusockets_full.a
|
||||||
#flag -luv
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#include "libusockets.h"
|
#include "libusockets.h"
|
||||||
@@ -105,8 +100,6 @@ pub fn (ctx SocketContext) on_writable(h voidptr) { C.us_socket_context_on_writa
|
|||||||
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
|
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
|
||||||
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
|
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
|
||||||
pub fn (ctx SocketContext) listen(port int) ListenSocket {
|
pub fn (ctx SocketContext) listen(port int) ListenSocket {
|
||||||
// 参数: ssl=0, context, host=nil, port, options=LIBUS_LISTEN_DEFAULT(0), socket_ext_size=0
|
|
||||||
// 注意: backlog 是在 uSockets 库内部硬编码的,不通过此函数传递
|
|
||||||
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
|
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-17
@@ -183,7 +183,7 @@ fn handle_usockets_ws_upgrade(s usockets.Socket, raw_data string, route_match Co
|
|||||||
// Check if this is a WebSocket upgrade response
|
// Check if this is a WebSocket upgrade response
|
||||||
if response.status_code != 101 {
|
if response.status_code != 101 {
|
||||||
// Not a WebSocket upgrade, send the response as-is
|
// Not a WebSocket upgrade, send the response as-is
|
||||||
send_usockets_response(s, mutable_ctx, response)
|
send_usockets_response(s, mutable_ctx, response, true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
|
|||||||
|
|
||||||
// 解析 HTTP 请求
|
// 解析 HTTP 请求
|
||||||
raw_data := unsafe { tos(&u8(data), length) }
|
raw_data := unsafe { tos(&u8(data), length) }
|
||||||
method, path, query_map, body := parse_http_request_usockets(raw_data)
|
method, path, query_map, body, is_http11 := parse_http_request_usockets(raw_data)
|
||||||
|
|
||||||
if method.len == 0 {
|
if method.len == 0 {
|
||||||
s.write_bytes('HTTP/1.1 400 Bad Request\r\nContent-Length: 11\r\n\r\nBad Request')
|
s.write_bytes('HTTP/1.1 400 Bad Request\r\nContent-Length: 11\r\n\r\nBad Request')
|
||||||
@@ -259,11 +259,11 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
|
|||||||
// 优化1:零中间件快速路径
|
// 优化1:零中间件快速路径
|
||||||
if !g_usockets_app.has_middlewares {
|
if !g_usockets_app.has_middlewares {
|
||||||
response := route_match.handler.handle(mut hono_ctx)
|
response := route_match.handler.handle(mut hono_ctx)
|
||||||
send_usockets_response(s, hono_ctx, response)
|
send_usockets_response(s, hono_ctx, response, is_http11)
|
||||||
} else {
|
} else {
|
||||||
middlewares := get_middlewares_for_path_usockets_optimized(g_usockets_app, path)
|
middlewares := get_middlewares_for_path_usockets_optimized(g_usockets_app, path)
|
||||||
response := exec_middlewares_usockets(0, middlewares, mut hono_ctx, route_match.handler)
|
response := exec_middlewares_usockets(0, middlewares, mut hono_ctx, route_match.handler)
|
||||||
send_usockets_response(s, hono_ctx, response)
|
send_usockets_response(s, hono_ctx, response, is_http11)
|
||||||
}
|
}
|
||||||
response_sent = true
|
response_sent = true
|
||||||
}
|
}
|
||||||
@@ -289,11 +289,11 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
|
|||||||
// 优化1:零中间件快速路径
|
// 优化1:零中间件快速路径
|
||||||
if !g_usockets_app.has_middlewares {
|
if !g_usockets_app.has_middlewares {
|
||||||
response := route_match.handler.handle(mut hono_ctx)
|
response := route_match.handler.handle(mut hono_ctx)
|
||||||
send_usockets_response(s, hono_ctx, response)
|
send_usockets_response(s, hono_ctx, response, is_http11)
|
||||||
} else {
|
} else {
|
||||||
middlewares := get_middlewares_for_path_usockets_optimized(g_usockets_app, path)
|
middlewares := get_middlewares_for_path_usockets_optimized(g_usockets_app, path)
|
||||||
response := exec_middlewares_usockets(0, middlewares, mut hono_ctx, route_match.handler)
|
response := exec_middlewares_usockets(0, middlewares, mut hono_ctx, route_match.handler)
|
||||||
send_usockets_response(s, hono_ctx, response)
|
send_usockets_response(s, hono_ctx, response, is_http11)
|
||||||
}
|
}
|
||||||
response_sent = true
|
response_sent = true
|
||||||
}
|
}
|
||||||
@@ -305,9 +305,13 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
|
|||||||
|
|
||||||
if handler := g_usockets_app.not_found_handler {
|
if handler := g_usockets_app.not_found_handler {
|
||||||
response := handler(mut hono_ctx)
|
response := handler(mut hono_ctx)
|
||||||
send_usockets_response(s, hono_ctx, response)
|
send_usockets_response(s, hono_ctx, response, is_http11)
|
||||||
} else {
|
} else {
|
||||||
s.write_bytes('HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: keep-alive\r\n\r\nNot Found')
|
conn_header := if is_http11 { 'keep-alive' } else { 'close' }
|
||||||
|
s.write_bytes('HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: ${conn_header}\r\n\r\nNot Found')
|
||||||
|
if !is_http11 {
|
||||||
|
s.shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,15 +338,17 @@ fn usockets_on_end(s usockets.Socket) usockets.Socket {
|
|||||||
|
|
||||||
// 解析 HTTP 请求 - 零分配优化版
|
// 解析 HTTP 请求 - 零分配优化版
|
||||||
// 使用指针遍历避免创建临时数组
|
// 使用指针遍历避免创建临时数组
|
||||||
fn parse_http_request_usockets(raw string) (string, string, map[string]string, string) {
|
// 返回: (method, path, query_map, body, is_http11)
|
||||||
|
fn parse_http_request_usockets(raw string) (string, string, map[string]string, string, bool) {
|
||||||
mut method := ''
|
mut method := ''
|
||||||
mut path := ''
|
mut path := ''
|
||||||
mut query_map := map[string]string{}
|
mut query_map := map[string]string{}
|
||||||
mut body := ''
|
mut body := ''
|
||||||
|
mut is_http11 := true
|
||||||
|
|
||||||
len := raw.len
|
len := raw.len
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return method, path, query_map, body
|
return method, path, query_map, body, is_http11
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 查找第一行结束位置(\r\n)
|
// 1. 查找第一行结束位置(\r\n)
|
||||||
@@ -354,7 +360,7 @@ fn parse_http_request_usockets(raw string) (string, string, map[string]string, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if line_end == -1 {
|
if line_end == -1 {
|
||||||
return method, path, query_map, body
|
return method, path, query_map, body, is_http11
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 解析 method(到第一个空格)
|
// 2. 解析 method(到第一个空格)
|
||||||
@@ -363,7 +369,7 @@ fn parse_http_request_usockets(raw string) (string, string, map[string]string, s
|
|||||||
method_end++
|
method_end++
|
||||||
}
|
}
|
||||||
if method_end == 0 || method_end >= line_end {
|
if method_end == 0 || method_end >= line_end {
|
||||||
return method, path, query_map, body
|
return method, path, query_map, body, is_http11
|
||||||
}
|
}
|
||||||
method = raw[..method_end]
|
method = raw[..method_end]
|
||||||
|
|
||||||
@@ -391,7 +397,25 @@ fn parse_http_request_usockets(raw string) (string, string, map[string]string, s
|
|||||||
path = raw[path_start..path_end]
|
path = raw[path_start..path_end]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 查找 body(\r\n\r\n 之后)
|
// 4. 检测 HTTP 版本 - "HTTP/1.0" vs "HTTP/1.1"
|
||||||
|
// 请求行格式: "GET /path HTTP/1.1\r\n"
|
||||||
|
// HTTP/1.x 版本字符串固定为 8 字符,直接检查第 8 个字符
|
||||||
|
mut version_start := path_end + 1
|
||||||
|
for version_start < line_end && raw[version_start] == ` ` {
|
||||||
|
version_start++
|
||||||
|
}
|
||||||
|
// "HTTP/1.0" 或 "HTTP/1.1",检查索引 7 的字符(0-based)
|
||||||
|
if version_start + 7 < line_end {
|
||||||
|
is_http11 = raw[version_start + 7] != `0`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于 HTTP/1.0,检查 Connection 头是否包含 keep-alive
|
||||||
|
// 参考 Go net/http: wantsHttp10KeepAlive() 只检查 Connection 头的值
|
||||||
|
if !is_http11 {
|
||||||
|
is_http11 = has_connection_keep_alive(raw, line_end, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 查找 body(\r\n\r\n 之后)
|
||||||
for i in line_end .. len - 3 {
|
for i in line_end .. len - 3 {
|
||||||
if raw[i] == `\r` && raw[i + 1] == `\n` && raw[i + 2] == `\r` && raw[i + 3] == `\n` {
|
if raw[i] == `\r` && raw[i + 1] == `\n` && raw[i + 2] == `\r` && raw[i + 3] == `\n` {
|
||||||
if i + 4 < len {
|
if i + 4 < len {
|
||||||
@@ -401,10 +425,82 @@ fn parse_http_request_usockets(raw string) (string, string, map[string]string, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return method, path, query_map, body
|
return method, path, query_map, body, is_http11
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快速解析 query string(单次遍历,避免 split)
|
// 检查 Connection 头是否包含 keep-alive(参考 Go net/http 实现)
|
||||||
|
// 只检查 "Connection:" 头的值,避免误判请求体中的内容
|
||||||
|
@[inline]
|
||||||
|
fn has_connection_keep_alive(raw string, headers_start int, len int) bool {
|
||||||
|
mut i := headers_start
|
||||||
|
|
||||||
|
// 遍历每一行头部
|
||||||
|
for i < len - 12 { // 至少需要 "Connection:" + 一些值
|
||||||
|
// 跳过 \r\n
|
||||||
|
if raw[i] == `\r` && i + 1 < len && raw[i + 1] == `\n` {
|
||||||
|
i += 2
|
||||||
|
// 检查是否到达头部结束(空行)
|
||||||
|
if i < len - 1 && raw[i] == `\r` && raw[i + 1] == `\n` {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是 "Connection:" 头(不区分大小写)
|
||||||
|
if (raw[i] == `C` || raw[i] == `c`) && i + 11 < len {
|
||||||
|
// 检查 "onnection:"
|
||||||
|
if (raw[i+1] == `o` || raw[i+1] == `O`) &&
|
||||||
|
(raw[i+2] == `n` || raw[i+2] == `N`) &&
|
||||||
|
(raw[i+3] == `n` || raw[i+3] == `N`) &&
|
||||||
|
(raw[i+4] == `e` || raw[i+4] == `E`) &&
|
||||||
|
(raw[i+5] == `c` || raw[i+5] == `C`) &&
|
||||||
|
(raw[i+6] == `t` || raw[i+6] == `T`) &&
|
||||||
|
(raw[i+7] == `i` || raw[i+7] == `I`) &&
|
||||||
|
(raw[i+8] == `o` || raw[i+8] == `O`) &&
|
||||||
|
(raw[i+9] == `n` || raw[i+9] == `N`) &&
|
||||||
|
raw[i+10] == `:` {
|
||||||
|
// 找到 Connection 头,检查值是否包含 keep-alive
|
||||||
|
mut value_start := i + 11
|
||||||
|
// 跳过空格
|
||||||
|
for value_start < len && raw[value_start] == ` ` {
|
||||||
|
value_start++
|
||||||
|
}
|
||||||
|
// 查找行尾
|
||||||
|
mut value_end := value_start
|
||||||
|
for value_end < len && raw[value_end] != `\r` && raw[value_end] != `\n` {
|
||||||
|
value_end++
|
||||||
|
}
|
||||||
|
// 在值中查找 "keep-alive"(不区分大小写)
|
||||||
|
return contains_keep_alive_token(raw, value_start, value_end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查字符串片段是否包含 keep-alive token
|
||||||
|
@[inline]
|
||||||
|
fn contains_keep_alive_token(raw string, start int, end int) bool {
|
||||||
|
// 查找 "keep-alive" 或 "Keep-Alive"(支持多值如 "keep-alive, upgrade")
|
||||||
|
mut i := start
|
||||||
|
for i <= end - 10 {
|
||||||
|
if raw[i] == `k` || raw[i] == `K` {
|
||||||
|
if (raw[i+1] == `e` || raw[i+1] == `E`) &&
|
||||||
|
(raw[i+2] == `e` || raw[i+2] == `E`) &&
|
||||||
|
(raw[i+3] == `p` || raw[i+3] == `P`) &&
|
||||||
|
raw[i+4] == `-` &&
|
||||||
|
(raw[i+5] == `a` || raw[i+5] == `A`) &&
|
||||||
|
(raw[i+6] == `l` || raw[i+6] == `L`) &&
|
||||||
|
(raw[i+7] == `i` || raw[i+7] == `I`) &&
|
||||||
|
(raw[i+8] == `v` || raw[i+8] == `V`) &&
|
||||||
|
(raw[i+9] == `e` || raw[i+9] == `E`) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}// 快速解析 query string(单次遍历,避免 split)
|
||||||
@[inline]
|
@[inline]
|
||||||
fn parse_query_string_fast(raw string, start int, end int) map[string]string {
|
fn parse_query_string_fast(raw string, start int, end int) map[string]string {
|
||||||
mut query_map := map[string]string{}
|
mut query_map := map[string]string{}
|
||||||
@@ -576,7 +672,7 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送 uSockets 响应 - 优化版
|
// 发送 uSockets 响应 - 优化版
|
||||||
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
|
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response, is_http11 bool) {
|
||||||
// Check if this is a streaming response
|
// Check if this is a streaming response
|
||||||
if is_streaming_response(ctx) {
|
if is_streaming_response(ctx) {
|
||||||
// Handle streaming response
|
// Handle streaming response
|
||||||
@@ -630,14 +726,25 @@ fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response
|
|||||||
resp.write_string(response.body.len.str())
|
resp.write_string(response.body.len.str())
|
||||||
resp.write_string('\r\n')
|
resp.write_string('\r\n')
|
||||||
|
|
||||||
// Connection
|
// Connection - HTTP/1.0 用 close,HTTP/1.1 用 keep-alive
|
||||||
|
if is_http11 {
|
||||||
resp.write_string('Connection: keep-alive\r\n')
|
resp.write_string('Connection: keep-alive\r\n')
|
||||||
|
} else {
|
||||||
|
resp.write_string('Connection: close\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
// 空行 + body
|
// 空行 + body
|
||||||
resp.write_string('\r\n')
|
resp.write_string('\r\n')
|
||||||
resp.write_string(response.body)
|
resp.write_string(response.body)
|
||||||
|
|
||||||
s.write_bytes(resp.str())
|
s.write_bytes(resp.str())
|
||||||
|
|
||||||
|
// HTTP/1.0 请求完成后关闭连接
|
||||||
|
// 只调用 shutdown() 发送 FIN,让 on_end 回调处理 close()
|
||||||
|
// 这样可以确保发送缓冲区的数据完全发送
|
||||||
|
if !is_http11 {
|
||||||
|
s.shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Module {
|
Module {
|
||||||
name: 'hono'
|
name: 'hono'
|
||||||
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
|
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
|
||||||
version: '0.7.2'
|
version: '0.8.0'
|
||||||
license: 'MIT'
|
license: 'MIT'
|
||||||
dependencies: []
|
dependencies: []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user