feat: add OpenAPI/Swagger UI support with interactive API documentation
- Add OpenAPI 3.0 specification builder with fluent API - Add Swagger UI integration for interactive API documentation - Add comprehensive OpenAPI data structures (schemas, parameters, responses, etc.) - Add swagger_demo.v example with Pet Store API demonstration - Use global variables for shared mutable state in demo handlers - Add property-based tests for OpenAPI serialization and validation - Update README with Swagger UI documentation
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -1014,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
|
||||||
@@ -1120,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
|
||||||
@@ -1138,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user