diff --git a/README.md b/README.md
index a727bd0..351eeca 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
- 🗄️ **Database** - SQLite integration for data persistence
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
+- 📖 **Swagger UI** - Interactive API documentation with OpenAPI 3.0/3.1 support
## Installation
@@ -1014,6 +1015,201 @@ eventSource.onerror = (e) => {
```
+### 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
```v
@@ -1120,6 +1316,8 @@ v-hono/
├── validator.v # Request validation
├── websocket.v # WebSocket helper (RFC 6455)
├── 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
├── usockets_server.v # uSockets backend with WebSocket/SSE support
├── v.mod # Module definition
@@ -1138,6 +1336,7 @@ See the `examples/` directory for more examples:
- `examples/middleware_demo.v` - Built-in middleware demonstration
- `examples/route_grouping/` - Route grouping
- `examples/redirect_demo.v` - Redirect functionality examples
+- `examples/swagger_demo.v` - Swagger UI and OpenAPI documentation
## License
diff --git a/app.v b/app.v
index 56fd8b2..abf52cf 100644
--- a/app.v
+++ b/app.v
@@ -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 版本
@[inline]
fn parse_query_string_app(query_str string) map[string]string {
diff --git a/examples/swagger_demo.v b/examples/swagger_demo.v
new file mode 100644
index 0000000..71f1505
--- /dev/null
+++ b/examples/swagger_demo.v
@@ -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)
+}
diff --git a/middleware.v b/middleware.v
index aa04ad4..d377c58 100644
--- a/middleware.v
+++ b/middleware.v
@@ -11,7 +11,7 @@ import rand
// 中间件导出说明
// ============================================================================
//
-// 本框架提供以下 8 个内置中间件:
+// 本框架提供以下 9 个内置中间件:
//
// 1. CORS 中间件 (cors.v)
// - cors(options ...CorsOptions) ContextMiddleware
@@ -82,6 +82,57 @@ import rand
// - encode_ws_frame(opcode u8, payload []u8, masked bool) []u8: 编码 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 导出
// ============================================================================
diff --git a/openapi.v b/openapi.v
new file mode 100644
index 0000000..b904c3a
--- /dev/null
+++ b/openapi.v
@@ -0,0 +1,1822 @@
+// openapi.v - OpenAPI 3.0/3.1 规范数据结构
+// 本模块提供 OpenAPI 文档的数据结构定义,支持 OpenAPI 3.0.x 和 3.1.x 规范
+module hono
+
+import x.json2
+
+// ============================================================================
+// OpenAPI 基础结构体 (Task 1.1)
+// ============================================================================
+
+// OpenAPIContact - 联系信息
+pub struct OpenAPIContact {
+pub mut:
+ name string
+ url string
+ email string
+}
+
+// OpenAPILicense - 许可证信息
+pub struct OpenAPILicense {
+pub mut:
+ name string // 必需
+ url string
+}
+
+// OpenAPIInfo - API 基本信息
+pub struct OpenAPIInfo {
+pub mut:
+ title string // 必需
+ version string // 必需
+ description string
+ terms_of_service string
+ contact OpenAPIContact
+ license OpenAPILicense
+}
+
+// OpenAPIServer - 服务器信息
+pub struct OpenAPIServer {
+pub mut:
+ url string // 必需
+ description string
+}
+
+// OpenAPIExternalDocs - 外部文档
+pub struct OpenAPIExternalDocs {
+pub mut:
+ url string // 必需
+ description string
+}
+
+// OpenAPITag - 标签定义
+pub struct OpenAPITag {
+pub mut:
+ name string // 必需
+ description string
+ external_docs OpenAPIExternalDocs
+}
+
+
+// ============================================================================
+// OpenAPI 路径和操作结构体 (Task 1.2)
+// ============================================================================
+
+// OpenAPIMediaType - 媒体类型
+pub struct OpenAPIMediaType {
+pub mut:
+ schema OpenAPISchema
+ example string
+}
+
+// OpenAPIHeader - 响应头
+pub struct OpenAPIHeader {
+pub mut:
+ description string
+ required bool
+ schema OpenAPISchema
+}
+
+// OpenAPIResponse - 响应定义
+pub struct OpenAPIResponse {
+pub mut:
+ description string // 必需
+ headers map[string]OpenAPIHeader
+ content map[string]OpenAPIMediaType
+}
+
+// OpenAPIRequestBody - 请求体定义
+pub struct OpenAPIRequestBody {
+pub mut:
+ description string
+ content map[string]OpenAPIMediaType // 必需
+ required bool
+}
+
+// OpenAPIParameter - 参数定义
+pub struct OpenAPIParameter {
+pub mut:
+ name string // 必需
+ in_location string // 必需: "path", "query", "header", "cookie"
+ description string
+ required bool
+ deprecated bool
+ schema OpenAPISchema
+ example string
+}
+
+// OpenAPIOperation - 操作定义
+pub struct OpenAPIOperation {
+pub mut:
+ summary string
+ description string
+ operation_id string
+ tags []string
+ parameters []OpenAPIParameter
+ request_body OpenAPIRequestBody
+ responses map[string]OpenAPIResponse // 必需
+ deprecated bool
+ security []map[string][]string
+}
+
+// OpenAPIPathItem - 路径项
+pub struct OpenAPIPathItem {
+pub mut:
+ get OpenAPIOperation
+ post OpenAPIOperation
+ put OpenAPIOperation
+ delete OpenAPIOperation
+ patch OpenAPIOperation
+ head OpenAPIOperation
+ options OpenAPIOperation
+ summary string
+ description string
+ parameters []OpenAPIParameter
+}
+
+
+// ============================================================================
+// OpenAPI Schema 和 Components 结构体 (Task 1.3)
+// ============================================================================
+
+// OpenAPISchema - JSON Schema 定义
+pub struct OpenAPISchema {
+pub mut:
+ schema_type string // "string", "integer", "number", "boolean", "array", "object"
+ format string // "int32", "int64", "float", "double", "date", "date-time", etc.
+ title string
+ description string
+ default_val string
+ example string
+ enum_values []string
+ required []string
+ properties map[string]OpenAPISchema
+ items &OpenAPISchema = unsafe { nil } // for array type
+ minimum f64
+ maximum f64
+ min_length int
+ max_length int
+ pattern string
+ nullable bool
+ read_only bool
+ write_only bool
+ ref string // $ref 引用
+}
+
+// OpenAPISecurityScheme - 安全方案
+pub struct OpenAPISecurityScheme {
+pub mut:
+ scheme_type string // "apiKey", "http", "oauth2", "openIdConnect"
+ description string
+ name string // for apiKey
+ in_location string // for apiKey: "query", "header", "cookie"
+ scheme string // for http: "bearer", "basic"
+ bearer_format string // for http bearer
+}
+
+// OpenAPIComponents - 可重用组件
+pub struct OpenAPIComponents {
+pub mut:
+ schemas map[string]OpenAPISchema
+ responses map[string]OpenAPIResponse
+ parameters map[string]OpenAPIParameter
+ request_bodies map[string]OpenAPIRequestBody
+ headers map[string]OpenAPIHeader
+ security_schemes map[string]OpenAPISecurityScheme
+}
+
+// OpenAPIDocument - OpenAPI 文档主结构
+pub struct OpenAPIDocument {
+pub mut:
+ openapi string // 必需: "3.0.0" 或 "3.1.0"
+ info OpenAPIInfo // 必需
+ servers []OpenAPIServer
+ paths map[string]OpenAPIPathItem // 必需
+ components OpenAPIComponents
+ security []map[string][]string
+ tags []OpenAPITag
+ external_docs OpenAPIExternalDocs
+}
+
+
+// ============================================================================
+// OpenAPI 文档序列化 (Task 2.1)
+// ============================================================================
+
+// Helper function to check if a string is empty
+fn is_empty(s string) bool {
+ return s.len == 0
+}
+
+// Helper function to check if a map is empty
+fn is_map_empty[K, V](m map[K]V) bool {
+ return m.len == 0
+}
+
+// Helper function to check if an array is empty
+fn is_array_empty[T](arr []T) bool {
+ return arr.len == 0
+}
+
+// to_json - 将 OpenAPIContact 序列化为 JSON
+pub fn (c OpenAPIContact) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_empty(c.name) {
+ obj['name'] = json2.Any(c.name)
+ }
+ if !is_empty(c.url) {
+ obj['url'] = json2.Any(c.url)
+ }
+ if !is_empty(c.email) {
+ obj['email'] = json2.Any(c.email)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPILicense 序列化为 JSON
+pub fn (l OpenAPILicense) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // name is required
+ obj['name'] = json2.Any(l.name)
+ if !is_empty(l.url) {
+ obj['url'] = json2.Any(l.url)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIInfo 序列化为 JSON
+pub fn (i OpenAPIInfo) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // title and version are required
+ obj['title'] = json2.Any(i.title)
+ obj['version'] = json2.Any(i.version)
+ if !is_empty(i.description) {
+ obj['description'] = json2.Any(i.description)
+ }
+ if !is_empty(i.terms_of_service) {
+ obj['termsOfService'] = json2.Any(i.terms_of_service)
+ }
+ // Only include contact if it has any non-empty fields
+ if !is_empty(i.contact.name) || !is_empty(i.contact.url) || !is_empty(i.contact.email) {
+ obj['contact'] = i.contact.to_json()
+ }
+ // Only include license if name is set (required field)
+ if !is_empty(i.license.name) {
+ obj['license'] = i.license.to_json()
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIServer 序列化为 JSON
+pub fn (s OpenAPIServer) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // url is required
+ obj['url'] = json2.Any(s.url)
+ if !is_empty(s.description) {
+ obj['description'] = json2.Any(s.description)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIExternalDocs 序列化为 JSON
+pub fn (e OpenAPIExternalDocs) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // url is required
+ obj['url'] = json2.Any(e.url)
+ if !is_empty(e.description) {
+ obj['description'] = json2.Any(e.description)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPITag 序列化为 JSON
+pub fn (t OpenAPITag) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // name is required
+ obj['name'] = json2.Any(t.name)
+ if !is_empty(t.description) {
+ obj['description'] = json2.Any(t.description)
+ }
+ if !is_empty(t.external_docs.url) {
+ obj['externalDocs'] = t.external_docs.to_json()
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPISchema 序列化为 JSON
+pub fn (s OpenAPISchema) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+
+ // Handle $ref - if ref is set, only output $ref
+ if !is_empty(s.ref) {
+ obj[r'$ref'] = json2.Any(s.ref)
+ return json2.Any(obj)
+ }
+
+ // schema_type -> type
+ if !is_empty(s.schema_type) {
+ obj['type'] = json2.Any(s.schema_type)
+ }
+ if !is_empty(s.format) {
+ obj['format'] = json2.Any(s.format)
+ }
+ if !is_empty(s.title) {
+ obj['title'] = json2.Any(s.title)
+ }
+ if !is_empty(s.description) {
+ obj['description'] = json2.Any(s.description)
+ }
+ if !is_empty(s.default_val) {
+ obj['default'] = json2.Any(s.default_val)
+ }
+ if !is_empty(s.example) {
+ obj['example'] = json2.Any(s.example)
+ }
+ // enum_values -> enum
+ if !is_array_empty(s.enum_values) {
+ mut enum_arr := []json2.Any{}
+ for v in s.enum_values {
+ enum_arr << json2.Any(v)
+ }
+ obj['enum'] = json2.Any(enum_arr)
+ }
+ if !is_array_empty(s.required) {
+ mut req_arr := []json2.Any{}
+ for r in s.required {
+ req_arr << json2.Any(r)
+ }
+ obj['required'] = json2.Any(req_arr)
+ }
+ if !is_map_empty(s.properties) {
+ mut props := map[string]json2.Any{}
+ for key, val in s.properties {
+ props[key] = val.to_json()
+ }
+ obj['properties'] = json2.Any(props)
+ }
+ // items for array type
+ if s.items != unsafe { nil } {
+ obj['items'] = s.items.to_json()
+ }
+ if s.minimum != 0 {
+ obj['minimum'] = json2.Any(s.minimum)
+ }
+ if s.maximum != 0 {
+ obj['maximum'] = json2.Any(s.maximum)
+ }
+ if s.min_length != 0 {
+ obj['minLength'] = json2.Any(s.min_length)
+ }
+ if s.max_length != 0 {
+ obj['maxLength'] = json2.Any(s.max_length)
+ }
+ if !is_empty(s.pattern) {
+ obj['pattern'] = json2.Any(s.pattern)
+ }
+ if s.nullable {
+ obj['nullable'] = json2.Any(s.nullable)
+ }
+ if s.read_only {
+ obj['readOnly'] = json2.Any(s.read_only)
+ }
+ if s.write_only {
+ obj['writeOnly'] = json2.Any(s.write_only)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIHeader 序列化为 JSON
+pub fn (h OpenAPIHeader) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_empty(h.description) {
+ obj['description'] = json2.Any(h.description)
+ }
+ if h.required {
+ obj['required'] = json2.Any(h.required)
+ }
+ // Only include schema if it has content
+ if !is_empty(h.schema.schema_type) || !is_empty(h.schema.ref) {
+ obj['schema'] = h.schema.to_json()
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIMediaType 序列化为 JSON
+pub fn (m OpenAPIMediaType) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // Only include schema if it has content
+ if !is_empty(m.schema.schema_type) || !is_empty(m.schema.ref) || !is_map_empty(m.schema.properties) {
+ obj['schema'] = m.schema.to_json()
+ }
+ if !is_empty(m.example) {
+ obj['example'] = json2.Any(m.example)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIResponse 序列化为 JSON
+pub fn (r OpenAPIResponse) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // description is required
+ obj['description'] = json2.Any(r.description)
+ if !is_map_empty(r.headers) {
+ mut headers := map[string]json2.Any{}
+ for key, val in r.headers {
+ headers[key] = val.to_json()
+ }
+ obj['headers'] = json2.Any(headers)
+ }
+ if !is_map_empty(r.content) {
+ mut content := map[string]json2.Any{}
+ for key, val in r.content {
+ content[key] = val.to_json()
+ }
+ obj['content'] = json2.Any(content)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIRequestBody 序列化为 JSON
+pub fn (rb OpenAPIRequestBody) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_empty(rb.description) {
+ obj['description'] = json2.Any(rb.description)
+ }
+ // content is required
+ if !is_map_empty(rb.content) {
+ mut content := map[string]json2.Any{}
+ for key, val in rb.content {
+ content[key] = val.to_json()
+ }
+ obj['content'] = json2.Any(content)
+ }
+ if rb.required {
+ obj['required'] = json2.Any(rb.required)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIParameter 序列化为 JSON
+pub fn (p OpenAPIParameter) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // name is required
+ obj['name'] = json2.Any(p.name)
+ // in_location -> in (required)
+ obj['in'] = json2.Any(p.in_location)
+ if !is_empty(p.description) {
+ obj['description'] = json2.Any(p.description)
+ }
+ if p.required {
+ obj['required'] = json2.Any(p.required)
+ }
+ if p.deprecated {
+ obj['deprecated'] = json2.Any(p.deprecated)
+ }
+ // Only include schema if it has content
+ if !is_empty(p.schema.schema_type) || !is_empty(p.schema.ref) {
+ obj['schema'] = p.schema.to_json()
+ }
+ if !is_empty(p.example) {
+ obj['example'] = json2.Any(p.example)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIOperation 序列化为 JSON
+pub fn (op OpenAPIOperation) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_empty(op.summary) {
+ obj['summary'] = json2.Any(op.summary)
+ }
+ if !is_empty(op.description) {
+ obj['description'] = json2.Any(op.description)
+ }
+ if !is_empty(op.operation_id) {
+ obj['operationId'] = json2.Any(op.operation_id)
+ }
+ if !is_array_empty(op.tags) {
+ mut tags_arr := []json2.Any{}
+ for t in op.tags {
+ tags_arr << json2.Any(t)
+ }
+ obj['tags'] = json2.Any(tags_arr)
+ }
+ if !is_array_empty(op.parameters) {
+ mut params_arr := []json2.Any{}
+ for p in op.parameters {
+ params_arr << p.to_json()
+ }
+ obj['parameters'] = json2.Any(params_arr)
+ }
+ // Only include request_body if it has content
+ if !is_map_empty(op.request_body.content) {
+ obj['requestBody'] = op.request_body.to_json()
+ }
+ // responses is required
+ if !is_map_empty(op.responses) {
+ mut responses := map[string]json2.Any{}
+ for key, val in op.responses {
+ responses[key] = val.to_json()
+ }
+ obj['responses'] = json2.Any(responses)
+ }
+ if op.deprecated {
+ obj['deprecated'] = json2.Any(op.deprecated)
+ }
+ if !is_array_empty(op.security) {
+ mut sec_arr := []json2.Any{}
+ for sec in op.security {
+ mut sec_obj := map[string]json2.Any{}
+ for key, val in sec {
+ mut scopes := []json2.Any{}
+ for scope in val {
+ scopes << json2.Any(scope)
+ }
+ sec_obj[key] = json2.Any(scopes)
+ }
+ sec_arr << json2.Any(sec_obj)
+ }
+ obj['security'] = json2.Any(sec_arr)
+ }
+ return json2.Any(obj)
+}
+
+// Helper to check if an operation is empty (has no responses)
+fn is_operation_empty(op OpenAPIOperation) bool {
+ return is_map_empty(op.responses)
+}
+
+// to_json - 将 OpenAPIPathItem 序列化为 JSON
+pub fn (pi OpenAPIPathItem) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_empty(pi.summary) {
+ obj['summary'] = json2.Any(pi.summary)
+ }
+ if !is_empty(pi.description) {
+ obj['description'] = json2.Any(pi.description)
+ }
+ if !is_array_empty(pi.parameters) {
+ mut params_arr := []json2.Any{}
+ for p in pi.parameters {
+ params_arr << p.to_json()
+ }
+ obj['parameters'] = json2.Any(params_arr)
+ }
+ // HTTP methods - only include if they have responses
+ if !is_operation_empty(pi.get) {
+ obj['get'] = pi.get.to_json()
+ }
+ if !is_operation_empty(pi.post) {
+ obj['post'] = pi.post.to_json()
+ }
+ if !is_operation_empty(pi.put) {
+ obj['put'] = pi.put.to_json()
+ }
+ if !is_operation_empty(pi.delete) {
+ obj['delete'] = pi.delete.to_json()
+ }
+ if !is_operation_empty(pi.patch) {
+ obj['patch'] = pi.patch.to_json()
+ }
+ if !is_operation_empty(pi.head) {
+ obj['head'] = pi.head.to_json()
+ }
+ if !is_operation_empty(pi.options) {
+ obj['options'] = pi.options.to_json()
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPISecurityScheme 序列化为 JSON
+pub fn (ss OpenAPISecurityScheme) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // scheme_type -> type
+ if !is_empty(ss.scheme_type) {
+ obj['type'] = json2.Any(ss.scheme_type)
+ }
+ if !is_empty(ss.description) {
+ obj['description'] = json2.Any(ss.description)
+ }
+ if !is_empty(ss.name) {
+ obj['name'] = json2.Any(ss.name)
+ }
+ // in_location -> in
+ if !is_empty(ss.in_location) {
+ obj['in'] = json2.Any(ss.in_location)
+ }
+ if !is_empty(ss.scheme) {
+ obj['scheme'] = json2.Any(ss.scheme)
+ }
+ if !is_empty(ss.bearer_format) {
+ obj['bearerFormat'] = json2.Any(ss.bearer_format)
+ }
+ return json2.Any(obj)
+}
+
+// Helper to check if components is empty
+fn is_components_empty(c OpenAPIComponents) bool {
+ return is_map_empty(c.schemas) && is_map_empty(c.responses) &&
+ is_map_empty(c.parameters) && is_map_empty(c.request_bodies) &&
+ is_map_empty(c.headers) && is_map_empty(c.security_schemes)
+}
+
+// to_json - 将 OpenAPIComponents 序列化为 JSON
+pub fn (c OpenAPIComponents) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ if !is_map_empty(c.schemas) {
+ mut schemas := map[string]json2.Any{}
+ for key, val in c.schemas {
+ schemas[key] = val.to_json()
+ }
+ obj['schemas'] = json2.Any(schemas)
+ }
+ if !is_map_empty(c.responses) {
+ mut responses := map[string]json2.Any{}
+ for key, val in c.responses {
+ responses[key] = val.to_json()
+ }
+ obj['responses'] = json2.Any(responses)
+ }
+ if !is_map_empty(c.parameters) {
+ mut parameters := map[string]json2.Any{}
+ for key, val in c.parameters {
+ parameters[key] = val.to_json()
+ }
+ obj['parameters'] = json2.Any(parameters)
+ }
+ if !is_map_empty(c.request_bodies) {
+ mut request_bodies := map[string]json2.Any{}
+ for key, val in c.request_bodies {
+ request_bodies[key] = val.to_json()
+ }
+ obj['requestBodies'] = json2.Any(request_bodies)
+ }
+ if !is_map_empty(c.headers) {
+ mut headers := map[string]json2.Any{}
+ for key, val in c.headers {
+ headers[key] = val.to_json()
+ }
+ obj['headers'] = json2.Any(headers)
+ }
+ if !is_map_empty(c.security_schemes) {
+ mut security_schemes := map[string]json2.Any{}
+ for key, val in c.security_schemes {
+ security_schemes[key] = val.to_json()
+ }
+ obj['securitySchemes'] = json2.Any(security_schemes)
+ }
+ return json2.Any(obj)
+}
+
+// to_json - 将 OpenAPIDocument 序列化为 JSON Any 对象
+pub fn (doc OpenAPIDocument) to_json() json2.Any {
+ mut obj := map[string]json2.Any{}
+ // openapi is required
+ obj['openapi'] = json2.Any(doc.openapi)
+ // info is required
+ obj['info'] = doc.info.to_json()
+ // servers (optional)
+ if !is_array_empty(doc.servers) {
+ mut servers_arr := []json2.Any{}
+ for s in doc.servers {
+ servers_arr << s.to_json()
+ }
+ obj['servers'] = json2.Any(servers_arr)
+ }
+ // paths is required
+ mut paths := map[string]json2.Any{}
+ for key, val in doc.paths {
+ paths[key] = val.to_json()
+ }
+ obj['paths'] = json2.Any(paths)
+ // components (optional)
+ if !is_components_empty(doc.components) {
+ obj['components'] = doc.components.to_json()
+ }
+ // security (optional)
+ if !is_array_empty(doc.security) {
+ mut sec_arr := []json2.Any{}
+ for sec in doc.security {
+ mut sec_obj := map[string]json2.Any{}
+ for key, val in sec {
+ mut scopes := []json2.Any{}
+ for scope in val {
+ scopes << json2.Any(scope)
+ }
+ sec_obj[key] = json2.Any(scopes)
+ }
+ sec_arr << json2.Any(sec_obj)
+ }
+ obj['security'] = json2.Any(sec_arr)
+ }
+ // tags (optional)
+ if !is_array_empty(doc.tags) {
+ mut tags_arr := []json2.Any{}
+ for t in doc.tags {
+ tags_arr << t.to_json()
+ }
+ obj['tags'] = json2.Any(tags_arr)
+ }
+ // external_docs (optional)
+ if !is_empty(doc.external_docs.url) {
+ obj['externalDocs'] = doc.external_docs.to_json()
+ }
+ return json2.Any(obj)
+}
+
+// to_json_str - 将 OpenAPIDocument 序列化为 JSON 字符串
+pub fn (doc OpenAPIDocument) to_json_str() string {
+ return doc.to_json().str()
+}
+
+// to_json_pretty - 将 OpenAPIDocument 序列化为格式化的 JSON 字符串
+pub fn (doc OpenAPIDocument) to_json_pretty() string {
+ return json2.encode(doc.to_json(), prettify: true)
+}
+
+
+// ============================================================================
+// OpenAPI 文档反序列化 (Task 2.3)
+// ============================================================================
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIContact
+pub fn OpenAPIContact.from_json(j json2.Any) OpenAPIContact {
+ obj := j.as_map()
+ return OpenAPIContact{
+ name: if 'name' in obj { obj['name'].str() } else { '' }
+ url: if 'url' in obj { obj['url'].str() } else { '' }
+ email: if 'email' in obj { obj['email'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPILicense
+pub fn OpenAPILicense.from_json(j json2.Any) OpenAPILicense {
+ obj := j.as_map()
+ return OpenAPILicense{
+ name: if 'name' in obj { obj['name'].str() } else { '' }
+ url: if 'url' in obj { obj['url'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIInfo
+pub fn OpenAPIInfo.from_json(j json2.Any) OpenAPIInfo {
+ obj := j.as_map()
+ return OpenAPIInfo{
+ title: if 'title' in obj { obj['title'].str() } else { '' }
+ version: if 'version' in obj { obj['version'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ terms_of_service: if 'termsOfService' in obj { obj['termsOfService'].str() } else { '' }
+ contact: if 'contact' in obj { OpenAPIContact.from_json(obj['contact']) } else { OpenAPIContact{} }
+ license: if 'license' in obj { OpenAPILicense.from_json(obj['license']) } else { OpenAPILicense{} }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIServer
+pub fn OpenAPIServer.from_json(j json2.Any) OpenAPIServer {
+ obj := j.as_map()
+ return OpenAPIServer{
+ url: if 'url' in obj { obj['url'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIExternalDocs
+pub fn OpenAPIExternalDocs.from_json(j json2.Any) OpenAPIExternalDocs {
+ obj := j.as_map()
+ return OpenAPIExternalDocs{
+ url: if 'url' in obj { obj['url'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPITag
+pub fn OpenAPITag.from_json(j json2.Any) OpenAPITag {
+ obj := j.as_map()
+ return OpenAPITag{
+ name: if 'name' in obj { obj['name'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ external_docs: if 'externalDocs' in obj { OpenAPIExternalDocs.from_json(obj['externalDocs']) } else { OpenAPIExternalDocs{} }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPISchema
+pub fn OpenAPISchema.from_json(j json2.Any) OpenAPISchema {
+ return openapi_schema_from_json_impl(j)
+}
+
+// Helper function to parse OpenAPISchema from JSON
+fn openapi_schema_from_json_impl(j json2.Any) OpenAPISchema {
+ obj := j.as_map()
+
+ // Parse properties map
+ mut props := map[string]OpenAPISchema{}
+ if 'properties' in obj {
+ props_obj := obj['properties'].as_map()
+ for key, val in props_obj {
+ props[key] = openapi_schema_from_json_impl(val)
+ }
+ }
+
+ // Parse enum values
+ mut enum_vals := []string{}
+ if 'enum' in obj {
+ for e in obj['enum'].arr() {
+ enum_vals << e.str()
+ }
+ }
+
+ // Parse required array
+ mut required := []string{}
+ if 'required' in obj {
+ for r in obj['required'].arr() {
+ required << r.str()
+ }
+ }
+
+ mut schema := OpenAPISchema{
+ schema_type: if 'type' in obj { obj['type'].str() } else { '' }
+ format: if 'format' in obj { obj['format'].str() } else { '' }
+ title: if 'title' in obj { obj['title'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ default_val: if 'default' in obj { obj['default'].str() } else { '' }
+ example: if 'example' in obj { obj['example'].str() } else { '' }
+ enum_values: enum_vals
+ required: required
+ properties: props
+ minimum: if 'minimum' in obj { obj['minimum'].f64() } else { 0 }
+ maximum: if 'maximum' in obj { obj['maximum'].f64() } else { 0 }
+ min_length: if 'minLength' in obj { obj['minLength'].int() } else { 0 }
+ max_length: if 'maxLength' in obj { obj['maxLength'].int() } else { 0 }
+ pattern: if 'pattern' in obj { obj['pattern'].str() } else { '' }
+ nullable: if 'nullable' in obj { obj['nullable'].bool() } else { false }
+ read_only: if 'readOnly' in obj { obj['readOnly'].bool() } else { false }
+ write_only: if 'writeOnly' in obj { obj['writeOnly'].bool() } else { false }
+ ref: if r'$ref' in obj { obj[r'$ref'].str() } else { '' }
+ }
+
+ // Parse items (for array type) - handle pointer separately
+ if 'items' in obj {
+ items_schema := openapi_schema_from_json_impl(obj['items'])
+ schema.items = &items_schema
+ }
+
+ return schema
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIHeader
+pub fn OpenAPIHeader.from_json(j json2.Any) OpenAPIHeader {
+ obj := j.as_map()
+ return OpenAPIHeader{
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ required: if 'required' in obj { obj['required'].bool() } else { false }
+ schema: if 'schema' in obj { OpenAPISchema.from_json(obj['schema']) } else { OpenAPISchema{} }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIMediaType
+pub fn OpenAPIMediaType.from_json(j json2.Any) OpenAPIMediaType {
+ obj := j.as_map()
+ return OpenAPIMediaType{
+ schema: if 'schema' in obj { OpenAPISchema.from_json(obj['schema']) } else { OpenAPISchema{} }
+ example: if 'example' in obj { obj['example'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIResponse
+pub fn OpenAPIResponse.from_json(j json2.Any) OpenAPIResponse {
+ obj := j.as_map()
+
+ // Parse headers map
+ mut headers := map[string]OpenAPIHeader{}
+ if 'headers' in obj {
+ headers_obj := obj['headers'].as_map()
+ for key, val in headers_obj {
+ headers[key] = OpenAPIHeader.from_json(val)
+ }
+ }
+
+ // Parse content map
+ mut content := map[string]OpenAPIMediaType{}
+ if 'content' in obj {
+ content_obj := obj['content'].as_map()
+ for key, val in content_obj {
+ content[key] = OpenAPIMediaType.from_json(val)
+ }
+ }
+
+ return OpenAPIResponse{
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ headers: headers
+ content: content
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIRequestBody
+pub fn OpenAPIRequestBody.from_json(j json2.Any) OpenAPIRequestBody {
+ obj := j.as_map()
+
+ // Parse content map
+ mut content := map[string]OpenAPIMediaType{}
+ if 'content' in obj {
+ content_obj := obj['content'].as_map()
+ for key, val in content_obj {
+ content[key] = OpenAPIMediaType.from_json(val)
+ }
+ }
+
+ return OpenAPIRequestBody{
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ content: content
+ required: if 'required' in obj { obj['required'].bool() } else { false }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIParameter
+pub fn OpenAPIParameter.from_json(j json2.Any) OpenAPIParameter {
+ obj := j.as_map()
+ return OpenAPIParameter{
+ name: if 'name' in obj { obj['name'].str() } else { '' }
+ in_location: if 'in' in obj { obj['in'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ required: if 'required' in obj { obj['required'].bool() } else { false }
+ deprecated: if 'deprecated' in obj { obj['deprecated'].bool() } else { false }
+ schema: if 'schema' in obj { OpenAPISchema.from_json(obj['schema']) } else { OpenAPISchema{} }
+ example: if 'example' in obj { obj['example'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIOperation
+pub fn OpenAPIOperation.from_json(j json2.Any) OpenAPIOperation {
+ obj := j.as_map()
+
+ // Parse tags array
+ mut tags := []string{}
+ if 'tags' in obj {
+ for t in obj['tags'].arr() {
+ tags << t.str()
+ }
+ }
+
+ // Parse parameters array
+ mut parameters := []OpenAPIParameter{}
+ if 'parameters' in obj {
+ for p in obj['parameters'].arr() {
+ parameters << OpenAPIParameter.from_json(p)
+ }
+ }
+
+ // Parse responses map
+ mut responses := map[string]OpenAPIResponse{}
+ if 'responses' in obj {
+ responses_obj := obj['responses'].as_map()
+ for key, val in responses_obj {
+ responses[key] = OpenAPIResponse.from_json(val)
+ }
+ }
+
+ // Parse security array
+ mut security := []map[string][]string{}
+ if 'security' in obj {
+ for sec in obj['security'].arr() {
+ mut sec_map := map[string][]string{}
+ sec_obj := sec.as_map()
+ for key, val in sec_obj {
+ mut scopes := []string{}
+ for scope in val.arr() {
+ scopes << scope.str()
+ }
+ sec_map[key] = scopes
+ }
+ security << sec_map
+ }
+ }
+
+ return OpenAPIOperation{
+ summary: if 'summary' in obj { obj['summary'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ operation_id: if 'operationId' in obj { obj['operationId'].str() } else { '' }
+ tags: tags
+ parameters: parameters
+ request_body: if 'requestBody' in obj { OpenAPIRequestBody.from_json(obj['requestBody']) } else { OpenAPIRequestBody{} }
+ responses: responses
+ deprecated: if 'deprecated' in obj { obj['deprecated'].bool() } else { false }
+ security: security
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIPathItem
+pub fn OpenAPIPathItem.from_json(j json2.Any) OpenAPIPathItem {
+ obj := j.as_map()
+
+ // Parse parameters array
+ mut parameters := []OpenAPIParameter{}
+ if 'parameters' in obj {
+ for p in obj['parameters'].arr() {
+ parameters << OpenAPIParameter.from_json(p)
+ }
+ }
+
+ return OpenAPIPathItem{
+ summary: if 'summary' in obj { obj['summary'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ parameters: parameters
+ get: if 'get' in obj { OpenAPIOperation.from_json(obj['get']) } else { OpenAPIOperation{} }
+ post: if 'post' in obj { OpenAPIOperation.from_json(obj['post']) } else { OpenAPIOperation{} }
+ put: if 'put' in obj { OpenAPIOperation.from_json(obj['put']) } else { OpenAPIOperation{} }
+ delete: if 'delete' in obj { OpenAPIOperation.from_json(obj['delete']) } else { OpenAPIOperation{} }
+ patch: if 'patch' in obj { OpenAPIOperation.from_json(obj['patch']) } else { OpenAPIOperation{} }
+ head: if 'head' in obj { OpenAPIOperation.from_json(obj['head']) } else { OpenAPIOperation{} }
+ options: if 'options' in obj { OpenAPIOperation.from_json(obj['options']) } else { OpenAPIOperation{} }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPISecurityScheme
+pub fn OpenAPISecurityScheme.from_json(j json2.Any) OpenAPISecurityScheme {
+ obj := j.as_map()
+ return OpenAPISecurityScheme{
+ scheme_type: if 'type' in obj { obj['type'].str() } else { '' }
+ description: if 'description' in obj { obj['description'].str() } else { '' }
+ name: if 'name' in obj { obj['name'].str() } else { '' }
+ in_location: if 'in' in obj { obj['in'].str() } else { '' }
+ scheme: if 'scheme' in obj { obj['scheme'].str() } else { '' }
+ bearer_format: if 'bearerFormat' in obj { obj['bearerFormat'].str() } else { '' }
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIComponents
+pub fn OpenAPIComponents.from_json(j json2.Any) OpenAPIComponents {
+ obj := j.as_map()
+
+ // Parse schemas map
+ mut schemas := map[string]OpenAPISchema{}
+ if 'schemas' in obj {
+ schemas_obj := obj['schemas'].as_map()
+ for key, val in schemas_obj {
+ schemas[key] = OpenAPISchema.from_json(val)
+ }
+ }
+
+ // Parse responses map
+ mut responses := map[string]OpenAPIResponse{}
+ if 'responses' in obj {
+ responses_obj := obj['responses'].as_map()
+ for key, val in responses_obj {
+ responses[key] = OpenAPIResponse.from_json(val)
+ }
+ }
+
+ // Parse parameters map
+ mut parameters := map[string]OpenAPIParameter{}
+ if 'parameters' in obj {
+ parameters_obj := obj['parameters'].as_map()
+ for key, val in parameters_obj {
+ parameters[key] = OpenAPIParameter.from_json(val)
+ }
+ }
+
+ // Parse request_bodies map
+ mut request_bodies := map[string]OpenAPIRequestBody{}
+ if 'requestBodies' in obj {
+ request_bodies_obj := obj['requestBodies'].as_map()
+ for key, val in request_bodies_obj {
+ request_bodies[key] = OpenAPIRequestBody.from_json(val)
+ }
+ }
+
+ // Parse headers map
+ mut headers := map[string]OpenAPIHeader{}
+ if 'headers' in obj {
+ headers_obj := obj['headers'].as_map()
+ for key, val in headers_obj {
+ headers[key] = OpenAPIHeader.from_json(val)
+ }
+ }
+
+ // Parse security_schemes map
+ mut security_schemes := map[string]OpenAPISecurityScheme{}
+ if 'securitySchemes' in obj {
+ security_schemes_obj := obj['securitySchemes'].as_map()
+ for key, val in security_schemes_obj {
+ security_schemes[key] = OpenAPISecurityScheme.from_json(val)
+ }
+ }
+
+ return OpenAPIComponents{
+ schemas: schemas
+ responses: responses
+ parameters: parameters
+ request_bodies: request_bodies
+ headers: headers
+ security_schemes: security_schemes
+ }
+}
+
+// from_json - 从 JSON Any 对象反序列化 OpenAPIDocument
+pub fn OpenAPIDocument.from_json(j json2.Any) OpenAPIDocument {
+ obj := j.as_map()
+
+ // Parse servers array
+ mut servers := []OpenAPIServer{}
+ if 'servers' in obj {
+ for s in obj['servers'].arr() {
+ servers << OpenAPIServer.from_json(s)
+ }
+ }
+
+ // Parse paths map
+ mut paths := map[string]OpenAPIPathItem{}
+ if 'paths' in obj {
+ paths_obj := obj['paths'].as_map()
+ for key, val in paths_obj {
+ paths[key] = OpenAPIPathItem.from_json(val)
+ }
+ }
+
+ // Parse security array
+ mut security := []map[string][]string{}
+ if 'security' in obj {
+ for sec in obj['security'].arr() {
+ mut sec_map := map[string][]string{}
+ sec_obj := sec.as_map()
+ for key, val in sec_obj {
+ mut scopes := []string{}
+ for scope in val.arr() {
+ scopes << scope.str()
+ }
+ sec_map[key] = scopes
+ }
+ security << sec_map
+ }
+ }
+
+ // Parse tags array
+ mut tags := []OpenAPITag{}
+ if 'tags' in obj {
+ for t in obj['tags'].arr() {
+ tags << OpenAPITag.from_json(t)
+ }
+ }
+
+ return OpenAPIDocument{
+ openapi: if 'openapi' in obj { obj['openapi'].str() } else { '' }
+ info: if 'info' in obj { OpenAPIInfo.from_json(obj['info']) } else { OpenAPIInfo{} }
+ servers: servers
+ paths: paths
+ components: if 'components' in obj { OpenAPIComponents.from_json(obj['components']) } else { OpenAPIComponents{} }
+ security: security
+ tags: tags
+ external_docs: if 'externalDocs' in obj { OpenAPIExternalDocs.from_json(obj['externalDocs']) } else { OpenAPIExternalDocs{} }
+ }
+}
+
+// from_json_str - 从 JSON 字符串反序列化 OpenAPIDocument
+pub fn OpenAPIDocument.from_json_str(json_str string) !OpenAPIDocument {
+ parsed := json2.decode[json2.Any](json_str)!
+ return OpenAPIDocument.from_json(parsed)
+}
+
+
+// ============================================================================
+// OpenAPI 文档验证 (Task 4.1)
+// ============================================================================
+
+// OpenAPIErrorKind - OpenAPI 错误类型
+pub enum OpenAPIErrorKind {
+ missing_required_field
+ invalid_version
+ invalid_path
+ invalid_parameter
+ invalid_schema
+ serialization_error
+ deserialization_error
+}
+
+// OpenAPIError - OpenAPI 相关错误
+pub struct OpenAPIError {
+ Error
+pub:
+ kind OpenAPIErrorKind
+ error_msg string
+ path string // 可选:问题路径
+ field string // 可选:问题字段
+}
+
+// msg - 实现 IError 接口
+pub fn (e OpenAPIError) msg() string {
+ return e.error_msg
+}
+
+// 支持的 OpenAPI 版本
+const supported_openapi_versions = ['3.0.0', '3.0.1', '3.0.2', '3.0.3', '3.1.0']
+
+// validate - 验证 OpenAPIDocument 的有效性
+// 验证必需字段、OpenAPI 版本和路径格式
+// 返回描述性错误信息
+pub fn (doc OpenAPIDocument) validate() ! {
+ // 1. 验证必需字段: openapi
+ if is_empty(doc.openapi) {
+ return error("Missing required field 'openapi' in OpenAPI document")
+ }
+
+ // 2. 验证 OpenAPI 版本 (3.0.x, 3.1.x)
+ if doc.openapi !in supported_openapi_versions {
+ return error("Unsupported OpenAPI version '${doc.openapi}'. Supported versions: ${supported_openapi_versions.join(', ')}")
+ }
+
+ // 3. 验证必需字段: info
+ if is_empty(doc.info.title) {
+ return error("Missing required field 'info.title' in OpenAPI document")
+ }
+
+ if is_empty(doc.info.version) {
+ return error("Missing required field 'info.version' in OpenAPI document")
+ }
+
+ // 4. 验证路径格式
+ for path, _ in doc.paths {
+ // 路径必须以 '/' 开头
+ if !path.starts_with('/') {
+ return error("Invalid path '${path}': paths must start with '/'")
+ }
+
+ // 检查路径参数格式 - 支持 {param} 和 :param 两种格式
+ // 验证 {param} 格式的括号匹配
+ mut open_braces := 0
+ mut last_open_idx := -1
+ for i, c in path {
+ if c == `{` {
+ open_braces++
+ last_open_idx = i
+ } else if c == `}` {
+ open_braces--
+ if open_braces < 0 {
+ return error("Invalid path '${path}': unmatched '}' at position ${i}")
+ }
+ }
+ }
+ if open_braces > 0 {
+ return error("Invalid path '${path}': unmatched '{' at position ${last_open_idx}")
+ }
+
+ // 检查空路径参数 {} 或 :
+ if path.contains('{}') {
+ return error("Invalid path '${path}': empty path parameter '{}'")
+ }
+
+ // 检查连续斜杠
+ if path.contains('//') {
+ return error("Invalid path '${path}': contains consecutive slashes '//'")
+ }
+ }
+
+ // 验证通过
+ return
+}
+
+// validate_with_details - 验证并返回所有错误(不仅仅是第一个)
+pub fn (doc OpenAPIDocument) validate_with_details() []OpenAPIError {
+ mut errors := []OpenAPIError{}
+
+ // 1. 验证必需字段: openapi
+ if is_empty(doc.openapi) {
+ errors << OpenAPIError{
+ kind: .missing_required_field
+ error_msg: "Missing required field 'openapi' in OpenAPI document"
+ field: 'openapi'
+ }
+ } else if doc.openapi !in supported_openapi_versions {
+ // 2. 验证 OpenAPI 版本
+ errors << OpenAPIError{
+ kind: .invalid_version
+ error_msg: "Unsupported OpenAPI version '${doc.openapi}'. Supported versions: ${supported_openapi_versions.join(', ')}"
+ field: 'openapi'
+ }
+ }
+
+ // 3. 验证必需字段: info
+ if is_empty(doc.info.title) {
+ errors << OpenAPIError{
+ kind: .missing_required_field
+ error_msg: "Missing required field 'info.title' in OpenAPI document"
+ field: 'info.title'
+ }
+ }
+
+ if is_empty(doc.info.version) {
+ errors << OpenAPIError{
+ kind: .missing_required_field
+ error_msg: "Missing required field 'info.version' in OpenAPI document"
+ field: 'info.version'
+ }
+ }
+
+ // 4. 验证路径格式
+ for path, _ in doc.paths {
+ // 路径必须以 '/' 开头
+ if !path.starts_with('/') {
+ errors << OpenAPIError{
+ kind: .invalid_path
+ error_msg: "Invalid path '${path}': paths must start with '/'"
+ path: path
+ }
+ continue
+ }
+
+ // 检查路径参数格式
+ mut open_braces := 0
+ mut last_open_idx := -1
+ mut has_brace_error := false
+ for i, c in path {
+ if c == `{` {
+ open_braces++
+ last_open_idx = i
+ } else if c == `}` {
+ open_braces--
+ if open_braces < 0 {
+ errors << OpenAPIError{
+ kind: .invalid_path
+ error_msg: "Invalid path '${path}': unmatched '}' at position ${i}"
+ path: path
+ }
+ has_brace_error = true
+ break
+ }
+ }
+ }
+ if !has_brace_error && open_braces > 0 {
+ errors << OpenAPIError{
+ kind: .invalid_path
+ error_msg: "Invalid path '${path}': unmatched '{' at position ${last_open_idx}"
+ path: path
+ }
+ }
+
+ // 检查空路径参数
+ if path.contains('{}') {
+ errors << OpenAPIError{
+ kind: .invalid_path
+ error_msg: "Invalid path '${path}': empty path parameter '{}'"
+ path: path
+ }
+ }
+
+ // 检查连续斜杠
+ if path.contains('//') {
+ errors << OpenAPIError{
+ kind: .invalid_path
+ error_msg: "Invalid path '${path}': contains consecutive slashes '//'"
+ path: path
+ }
+ }
+ }
+
+ return errors
+}
+
+// is_valid - 检查文档是否有效
+pub fn (doc OpenAPIDocument) is_valid() bool {
+ doc.validate() or { return false }
+ return true
+}
+
+
+// ============================================================================
+// OpenAPI 文档构建器 (Task 5)
+// ============================================================================
+
+// OpenAPIBuilder - 流式 API 构建器
+// 使用示例:
+// doc := OpenAPIBuilder.new()
+// .openapi('3.0.0')
+// .title('My API')
+// .version('1.0.0')
+// .description('API description')
+// .server('https://api.example.com', 'Production server')
+// .tag('users', 'User operations')
+// .path('/users')
+// .get(OpenAPIOperation{ ... })
+// .post(OpenAPIOperation{ ... })
+// .done()
+// .build()!
+pub struct OpenAPIBuilder {
+mut:
+ doc OpenAPIDocument
+}
+
+// new - 创建新的 OpenAPIBuilder 实例
+pub fn OpenAPIBuilder.new() OpenAPIBuilder {
+ return OpenAPIBuilder{
+ doc: OpenAPIDocument{
+ paths: map[string]OpenAPIPathItem{}
+ }
+ }
+}
+
+// openapi - 设置 OpenAPI 版本
+pub fn (mut b OpenAPIBuilder) openapi(version string) &OpenAPIBuilder {
+ b.doc.openapi = version
+ return unsafe { b }
+}
+
+// info - 设置完整的 API 信息
+pub fn (mut b OpenAPIBuilder) info(info OpenAPIInfo) &OpenAPIBuilder {
+ b.doc.info = info
+ return unsafe { b }
+}
+
+// title - 设置 API 标题
+pub fn (mut b OpenAPIBuilder) title(title string) &OpenAPIBuilder {
+ b.doc.info.title = title
+ return unsafe { b }
+}
+
+// version - 设置 API 版本
+pub fn (mut b OpenAPIBuilder) version(version string) &OpenAPIBuilder {
+ b.doc.info.version = version
+ return unsafe { b }
+}
+
+// description - 设置 API 描述
+pub fn (mut b OpenAPIBuilder) description(desc string) &OpenAPIBuilder {
+ b.doc.info.description = desc
+ return unsafe { b }
+}
+
+// terms_of_service - 设置服务条款 URL
+pub fn (mut b OpenAPIBuilder) terms_of_service(url string) &OpenAPIBuilder {
+ b.doc.info.terms_of_service = url
+ return unsafe { b }
+}
+
+// contact - 设置联系信息
+pub fn (mut b OpenAPIBuilder) contact(contact OpenAPIContact) &OpenAPIBuilder {
+ b.doc.info.contact = contact
+ return unsafe { b }
+}
+
+// license - 设置许可证信息
+pub fn (mut b OpenAPIBuilder) license(license OpenAPILicense) &OpenAPIBuilder {
+ b.doc.info.license = license
+ return unsafe { b }
+}
+
+// server - 添加服务器信息
+pub fn (mut b OpenAPIBuilder) server(url string, description string) &OpenAPIBuilder {
+ b.doc.servers << OpenAPIServer{
+ url: url
+ description: description
+ }
+ return unsafe { b }
+}
+
+// tag - 添加标签
+pub fn (mut b OpenAPIBuilder) tag(name string, description string) &OpenAPIBuilder {
+ b.doc.tags << OpenAPITag{
+ name: name
+ description: description
+ }
+ return unsafe { b }
+}
+
+// external_docs - 设置外部文档
+pub fn (mut b OpenAPIBuilder) external_docs(url string, description string) &OpenAPIBuilder {
+ b.doc.external_docs = OpenAPIExternalDocs{
+ url: url
+ description: description
+ }
+ return unsafe { b }
+}
+
+// add_schema - 添加可重用的 Schema 到 components
+pub fn (mut b OpenAPIBuilder) add_schema(name string, schema OpenAPISchema) &OpenAPIBuilder {
+ b.doc.components.schemas[name] = schema
+ return unsafe { b }
+}
+
+// add_response - 添加可重用的 Response 到 components
+pub fn (mut b OpenAPIBuilder) add_response(name string, response OpenAPIResponse) &OpenAPIBuilder {
+ b.doc.components.responses[name] = response
+ return unsafe { b }
+}
+
+// add_parameter - 添加可重用的 Parameter 到 components
+pub fn (mut b OpenAPIBuilder) add_parameter(name string, param OpenAPIParameter) &OpenAPIBuilder {
+ b.doc.components.parameters[name] = param
+ return unsafe { b }
+}
+
+// add_security_scheme - 添加安全方案到 components
+pub fn (mut b OpenAPIBuilder) add_security_scheme(name string, scheme OpenAPISecurityScheme) &OpenAPIBuilder {
+ b.doc.components.security_schemes[name] = scheme
+ return unsafe { b }
+}
+
+// security - 添加全局安全要求
+pub fn (mut b OpenAPIBuilder) security(requirements map[string][]string) &OpenAPIBuilder {
+ b.doc.security << requirements
+ return unsafe { b }
+}
+
+
+// ============================================================================
+// OpenAPIPathBuilder - 路径构建器 (Task 5.2)
+// ============================================================================
+
+// OpenAPIPathBuilder - 路径构建器
+// 用于为特定路径添加 HTTP 方法操作
+pub struct OpenAPIPathBuilder {
+mut:
+ parent &OpenAPIBuilder = unsafe { nil }
+ path string
+ path_item OpenAPIPathItem
+}
+
+// path - 开始构建路径,返回 OpenAPIPathBuilder
+pub fn (mut b OpenAPIBuilder) path(path string) OpenAPIPathBuilder {
+ // 如果路径已存在,获取现有的 path_item
+ existing := b.doc.paths[path] or { OpenAPIPathItem{} }
+ return OpenAPIPathBuilder{
+ parent: unsafe { b }
+ path: path
+ path_item: existing
+ }
+}
+
+// summary - 设置路径摘要
+pub fn (mut pb OpenAPIPathBuilder) summary(summary string) &OpenAPIPathBuilder {
+ pb.path_item.summary = summary
+ return unsafe { pb }
+}
+
+// path_description - 设置路径描述
+pub fn (mut pb OpenAPIPathBuilder) path_description(description string) &OpenAPIPathBuilder {
+ pb.path_item.description = description
+ return unsafe { pb }
+}
+
+// parameters - 设置路径级别的参数
+pub fn (mut pb OpenAPIPathBuilder) parameters(params []OpenAPIParameter) &OpenAPIPathBuilder {
+ pb.path_item.parameters = params
+ return unsafe { pb }
+}
+
+// get - 添加 GET 操作
+pub fn (mut pb OpenAPIPathBuilder) get(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.get = op
+ return unsafe { pb }
+}
+
+// post - 添加 POST 操作
+pub fn (mut pb OpenAPIPathBuilder) post(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.post = op
+ return unsafe { pb }
+}
+
+// put - 添加 PUT 操作
+pub fn (mut pb OpenAPIPathBuilder) put(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.put = op
+ return unsafe { pb }
+}
+
+// delete - 添加 DELETE 操作
+pub fn (mut pb OpenAPIPathBuilder) delete(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.delete = op
+ return unsafe { pb }
+}
+
+// patch - 添加 PATCH 操作
+pub fn (mut pb OpenAPIPathBuilder) patch(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.patch = op
+ return unsafe { pb }
+}
+
+// head - 添加 HEAD 操作
+pub fn (mut pb OpenAPIPathBuilder) head(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.head = op
+ return unsafe { pb }
+}
+
+// options - 添加 OPTIONS 操作
+pub fn (mut pb OpenAPIPathBuilder) options(op OpenAPIOperation) &OpenAPIPathBuilder {
+ pb.path_item.options = op
+ return unsafe { pb }
+}
+
+// done - 完成路径构建,返回父构建器
+pub fn (mut pb OpenAPIPathBuilder) done() &OpenAPIBuilder {
+ if pb.parent != unsafe { nil } {
+ pb.parent.doc.paths[pb.path] = pb.path_item
+ }
+ return unsafe { pb.parent }
+}
+
+
+// ============================================================================
+// OpenAPIBuilder build 方法 (Task 5.3)
+// ============================================================================
+
+// build - 构建并验证 OpenAPI 文档
+// 调用 validate() 验证文档,如果验证失败则返回错误
+pub fn (b OpenAPIBuilder) build() !OpenAPIDocument {
+ // 验证文档
+ b.doc.validate()!
+ return b.doc
+}
+
+// build_unchecked - 构建 OpenAPI 文档(不验证)
+// 用于需要跳过验证的场景
+pub fn (b OpenAPIBuilder) build_unchecked() OpenAPIDocument {
+ return b.doc
+}
+
+// validate - 验证当前构建的文档
+pub fn (b OpenAPIBuilder) validate() ! {
+ return b.doc.validate()
+}
+
+// get_document - 获取当前构建的文档(不验证)
+pub fn (b OpenAPIBuilder) get_document() OpenAPIDocument {
+ return b.doc
+}
+
+
+// ============================================================================
+// 路由信息提取 (Task 8)
+// ============================================================================
+
+// RouteInfo - 路由信息结构体
+// 包含路由的路径、HTTP 方法、路径参数和处理器信息
+// 用于自动生成 OpenAPI 文档
+pub struct RouteInfo {
+pub:
+ path string // 路由路径,如 "/users/:id"
+ method string // HTTP 方法,如 "GET", "POST"
+ path_params []string // 路径参数列表,如 ["id"]
+ handler IHandler // 路由处理器
+}
+
+// extract_path_params - 从路径中提取路径参数
+// 支持 :param 格式的路径参数
+// 例如: "/users/:id/posts/:postId" -> ["id", "postId"]
+fn extract_path_params(path string) []string {
+ mut params := []string{}
+ segments := path.split('/')
+ for segment in segments {
+ if segment.starts_with(':') && segment.len > 1 {
+ // 提取参数名(去掉冒号)
+ param_name := segment[1..]
+ params << param_name
+ }
+ }
+ return params
+}
+
+// get_routes - 获取应用的所有路由信息
+// 遍历所有注册的路由,包含子应用的路由
+// 提取路径参数
+// 返回 RouteInfo 数组
+pub fn (app Hono) get_routes() []RouteInfo {
+ mut routes := []RouteInfo{}
+
+ // 从 context_router 获取所有路由
+ // GET 路由
+ for handler in app.context_router.handlers.get {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'GET'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // POST 路由
+ for handler in app.context_router.handlers.post {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'POST'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // PUT 路由
+ for handler in app.context_router.handlers.put {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'PUT'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // DELETE 路由
+ for handler in app.context_router.handlers.delete {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'DELETE'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // PATCH 路由
+ for handler in app.context_router.handlers.patch {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'PATCH'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // HEAD 路由
+ for handler in app.context_router.handlers.head {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'HEAD'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // OPTIONS 路由
+ for handler in app.context_router.handlers.options {
+ routes << RouteInfo{
+ path: handler.path
+ method: 'OPTIONS'
+ path_params: extract_path_params(handler.path)
+ handler: handler
+ }
+ }
+
+ // 从子应用获取路由(已经在 route() 方法中合并到主应用)
+ // 子应用的路由已经通过 merge_routes_for_method 添加到 context_router 中
+ // 所以上面的遍历已经包含了子应用的路由
+
+ return routes
+}
+
+// generate_openapi_paths - 从路由信息生成 OpenAPI paths
+// 将 RouteInfo 数组转换为 OpenAPI PathItem 映射
+pub fn generate_openapi_paths(routes []RouteInfo) map[string]OpenAPIPathItem {
+ mut paths := map[string]OpenAPIPathItem{}
+
+ for route in routes {
+ // 将 :param 格式转换为 {param} 格式(OpenAPI 标准)
+ openapi_path := convert_path_to_openapi_format(route.path)
+
+ // 获取或创建 PathItem
+ mut path_item := paths[openapi_path] or { OpenAPIPathItem{} }
+
+ // 创建基本的 Operation
+ mut operation := OpenAPIOperation{
+ responses: {
+ '200': OpenAPIResponse{
+ description: 'Successful response'
+ }
+ }
+ }
+
+ // 添加路径参数
+ for param_name in route.path_params {
+ operation.parameters << OpenAPIParameter{
+ name: param_name
+ in_location: 'path'
+ required: true
+ schema: OpenAPISchema{
+ schema_type: 'string'
+ }
+ }
+ }
+
+ // 根据 HTTP 方法设置操作
+ match route.method {
+ 'GET' { path_item.get = operation }
+ 'POST' { path_item.post = operation }
+ 'PUT' { path_item.put = operation }
+ 'DELETE' { path_item.delete = operation }
+ 'PATCH' { path_item.patch = operation }
+ 'HEAD' { path_item.head = operation }
+ 'OPTIONS' { path_item.options = operation }
+ else {}
+ }
+
+ paths[openapi_path] = path_item
+ }
+
+ return paths
+}
+
+// convert_path_to_openapi_format - 将 v-hono 路径格式转换为 OpenAPI 格式
+// 将 :param 转换为 {param}
+// 例如: "/users/:id" -> "/users/{id}"
+fn convert_path_to_openapi_format(path string) string {
+ mut result := path
+ segments := path.split('/')
+ for segment in segments {
+ if segment.starts_with(':') && segment.len > 1 {
+ param_name := segment[1..]
+ result = result.replace(':${param_name}', '{${param_name}}')
+ }
+ }
+ return result
+}
diff --git a/swagger.v b/swagger.v
new file mode 100644
index 0000000..f83f26d
--- /dev/null
+++ b/swagger.v
@@ -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 {
+ ''
+ } else {
+ ''
+ }
+
+ // 构建自定义 JS URL 标签
+ custom_js_url_tag := if options.custom_js_url.len > 0 {
+ ''
+ } 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 '
+
+
+
+
+ ${options.title}
+
+ ${custom_css_url_tag}
+
+
+
+
+
+
+ ${custom_js_url_tag}
+
+
+'
+}
+
+
+// ============================================================================
+// 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)
+}
diff --git a/tests/test_openapi_builder_multi_ops_property.v b/tests/test_openapi_builder_multi_ops_property.v
new file mode 100644
index 0000000..4205e30
--- /dev/null
+++ b/tests/test_openapi_builder_multi_ops_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_http_methods_property.v b/tests/test_openapi_http_methods_property.v
new file mode 100644
index 0000000..fd2189b
--- /dev/null
+++ b/tests/test_openapi_http_methods_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_invalid_path_property.v b/tests/test_openapi_invalid_path_property.v
new file mode 100644
index 0000000..9306dc5
--- /dev/null
+++ b/tests/test_openapi_invalid_path_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_optional_fields_property.v b/tests/test_openapi_optional_fields_property.v
new file mode 100644
index 0000000..f636cd1
--- /dev/null
+++ b/tests/test_openapi_optional_fields_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_param_locations_property.v b/tests/test_openapi_param_locations_property.v
new file mode 100644
index 0000000..0eec3e0
--- /dev/null
+++ b/tests/test_openapi_param_locations_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_required_fields_property.v b/tests/test_openapi_required_fields_property.v
new file mode 100644
index 0000000..600d6c4
--- /dev/null
+++ b/tests/test_openapi_required_fields_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_roundtrip_property.v b/tests/test_openapi_roundtrip_property.v
new file mode 100644
index 0000000..8b66ded
--- /dev/null
+++ b/tests/test_openapi_roundtrip_property.v
@@ -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)
+ }
+}
diff --git a/tests/test_openapi_serialization.v b/tests/test_openapi_serialization.v
new file mode 100644
index 0000000..d2590f6
--- /dev/null
+++ b/tests/test_openapi_serialization.v
@@ -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()
+}
diff --git a/tests/test_openapi_structures.v b/tests/test_openapi_structures.v
new file mode 100644
index 0000000..8023a7a
--- /dev/null
+++ b/tests/test_openapi_structures.v
@@ -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()
+}
diff --git a/tests/test_route_extraction_property.v b/tests/test_route_extraction_property.v
new file mode 100644
index 0000000..bd79bb5
--- /dev/null
+++ b/tests/test_route_extraction_property.v
@@ -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))
+}
diff --git a/tests/test_swagger_ui_options_property.v b/tests/test_swagger_ui_options_property.v
new file mode 100644
index 0000000..ee68121
--- /dev/null
+++ b/tests/test_swagger_ui_options_property.v
@@ -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 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 tag
+fn test_title_in_html(options hono.SwaggerUIOptions, html string) bool {
+ expected := '${options.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('')
+ has_html_tag := html.contains('')
+ has_head := html.contains('') && html.contains('')
+ has_body := html.contains('') && html.contains('')
+ 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)
+ }
+}