Files
v-hono/tests/test_openapi_roundtrip_property.v
T
meiseayoung a043b24f69 feat: add OpenAPI/Swagger UI support with interactive API documentation
- Add OpenAPI 3.0 specification builder with fluent API
- Add Swagger UI integration for interactive API documentation
- Add comprehensive OpenAPI data structures (schemas, parameters, responses, etc.)
- Add swagger_demo.v example with Pet Store API demonstration
- Use global variables for shared mutable state in demo handlers
- Add property-based tests for OpenAPI serialization and validation
- Update README with Swagger UI documentation
2026-01-07 10:52:47 +08:00

487 lines
12 KiB
V

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