Compare commits

..
39 Commits
Author SHA1 Message Date
qinqx e7760ea80d feat: integrate multi-cloud storage (S3, OSS, COS, Local)
Add built-in storage support from v-hono-storage module:
- StorageProvider interface with 4 backends
- FileService with HTTP handlers
- Multipart upload support
- 86 property-based tests
- Retry mechanism with exponential backoff

Breaking: None (backward compatible)
2026-01-25 14:07:34 +08:00
meiseayoung fa26a136e6 fix: resolve compiler warnings
- websocket.v: fix signed type shift warning by casting to u32
- openapi.v: add or {} block for map indexing with sum type values
2026-01-07 14:25:26 +08:00
meiseayoung afbb6586fa chore: update v.mod version 2026-01-07 10:55:58 +08:00
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
meiseayoung 4d342b9198 docs: remove duplicate Windows prerequisites section 2026-01-07 08:00:26 +08:00
meiseayoung a6a859c566 docs: move uSockets build instructions to usockets/README.md 2026-01-07 07:57:05 +08:00
meiseayoung a2afd947d0 chore: update v.mod version 2026-01-06 17:03:34 +08:00
meiseayoung cfde1b4306 Merge remote-tracking branch: keep optimized HTTP/1.0 connection handling 2026-01-06 16:58:53 +08:00
meiseayoung 0a4e5d50f3 fix: HTTP/1.0 connection not closing causes ab benchmark to hang
- Add HTTP version detection in parse_http_request_usockets
- Return Connection: close header for HTTP/1.0 requests
- Call shutdown() for graceful connection close after HTTP/1.0 response
- Fix missing is_http11 parameter in WebSocket upgrade handler
- Support HTTP/1.0 with Connection: Keep-Alive header (ab -k mode)
- Optimize: only check Connection header value for keep-alive (like Go net/http)

Root cause: ab uses HTTP/1.0 by default, but server always returned
Connection: keep-alive, causing ab to wait for server to close connection.
2026-01-06 16:36:07 +08:00
meiseayoung ed55ce758e fix: HTTP/1.0 connection not closing causes ab benchmark to hang
- Add HTTP version detection in parse_http_request_usockets
- Return Connection: close header for HTTP/1.0 requests
- Call shutdown() for graceful connection close after HTTP/1.0 response
- Fix missing is_http11 parameter in WebSocket upgrade handler

Root cause: ab uses HTTP/1.0 by default, but server always returned
Connection: keep-alive, causing ab to wait for server to close connection.
2026-01-06 15:58:24 +08:00
meiseayoung 22e8dd4548 chore: update v.mod version 2026-01-06 14:11:40 +08:00
meiseayoung a702746df7 build(usockets): add pre-compiled library for MacOS-arm64 platform 2026-01-06 14:10:02 +08:00
meiseayoung a57ce2e77a chore: update v.mod version 2026-01-06 12:01:45 +08:00
meiseayoung 7a08c3142a build(usockets): add pre-compiled library for MacOS-x86 platform 2026-01-06 12:01:20 +08:00
meiseayoung 362015c1cf docs: update build instructions for uSockets backend
- Clarify that Windows requires gcc (tcc does not support .a static library format)
- Add separate build commands for macOS/Linux and Windows
- Document pre-compiled library locations by platform
- Add w64devkit as recommended MinGW download option
2026-01-05 12:25:06 +08:00
meiseayoung 0969901481 chore: update v.mod version 2026-01-05 11:50:46 +08:00
meiseayoung 6bddfc3731 build(usockets): add pre-compiled library for Linux platform 2026-01-05 11:49:47 +08:00
meiseayoung 26ac804b1d chore: update v.mod version 2026-01-05 11:07:41 +08:00
meiseayoung 56299b5485 refactor(usockets): reorganize lib directory structure for multi-platform support
- Organize lib directory by platform: windows/, macos-arm64/, macos-x64/, linux/
- Update build.sh to support unified build process for Windows/macOS/Linux
- Compile libuv + uSockets and merge into complete static library
- Update usockets.v conditional compilation to point to new directory structure
- Optimize benchmark tests with connection pooling for high concurrency testing
- Clean up unused legacy library files

Performance test results (Windows):
- 1000 concurrent, 1M requests: 35,223 req/s
- 10000 concurrent, 1M requests: 26,884 req/s
2026-01-05 11:06:56 +08:00
meiseayoung d31492fd59 chore: update v.mod version 2026-01-04 18:12:21 +08:00
meiseayoung e19f799e46 chore: upload usokets lib 2026-01-04 18:11:19 +08:00
meiseayoung f7e753969d fix: change the usockets/lib path 2026-01-04 15:44:54 +08:00
meiseayoung e9d14a35f6 fix(streaming): use direct socket fd writes for real-time SSE streaming
Previously, PicoevStreamWriter used picohttpparser.Response.raw() to write
streaming data, which only buffered the data until the entire request handler
completed. This caused SSE responses to be delayed until all sleep() calls
finished (e.g., 2.89s delay for /stream-text endpoint).

Changes:
- Modified PicoevStreamWriter to use socket fd directly from picohttpparser.Response
- Implemented write_to_fd() using C.send() for immediate data delivery
- Updated handle_picoev_streaming_response() to write HTTP headers directly to socket
- Added C.send() function declaration for direct socket writes

This fix enables true real-time streaming where each write() or write_sse()
call immediately sends data to the client, rather than buffering until
the callback completes.

All 74 test files pass with no regressions.
2026-01-04 15:43:01 +08:00
meiseayoung 9ae9170bcb feat: add SSE streaming helper support
Add Server-Sent Events (SSE) and streaming response support with three
streaming functions:

- stream(): Basic binary streaming with chunked transfer encoding
- stream_text(): Text streaming with proper Content-Type headers
- stream_sse(): SSE event streaming with full event field support

Features:
- SSEEvent struct with data, event, id, retry fields
- StreamContext for write, writeln, write_sse, sleep, pipe operations
- Multi-line data handling in SSE events
- Custom error handler support
- Client disconnect detection via on_abort callback
- Integrated with both Picoev and uSockets backends

New files:
- streaming.v: Core streaming implementation
- examples/sse_demo.v: Comprehensive SSE demo with test page
- tests/test_stream_*.v: 62 property-based tests

Updated:
- picoev_server.v: PicoevStreamWriter integration
- usockets_server.v: UsocketsStreamWriter integration
- README.md: SSE streaming documentation
2026-01-04 15:13:28 +08:00
meiseayoung 6ab6d3b99e feat(websocket): add WebSocket Helper with RFC 6455 compliance
Add comprehensive WebSocket support for v-hono framework, inspired by Hono.js WebSocket Helper.

Features:
- upgrade_websocket() function for WebSocket route handlers
- Event-based callbacks: on_open, on_message, on_close, on_error
- WSContext with send(), send_bytes(), send_json(), close() methods
- Connection state tracking (connecting, open, closing, closed)
- Configurable options: ping_interval, max_message_size, timeout, protocols
- Subprotocol negotiation support
- Ping/pong keep-alive handling
- Message size validation with 1009 close code
- Integration with picoev and uSockets backends

Protocol Compliance:
- RFC 6455 compliant handshake with Sec-WebSocket-Accept computation
- Frame encoding/decoding with proper masking
- Close handshake with status codes and reasons
- Text and binary message frame support

Testing:
- 13 property-based test files with 82 tests total
- Validates handshake, frame encoding, masking, JSON round-trip
- Tests context preservation, route patterns, connection state
- All existing tests pass (no regressions)

Files added:
- websocket.v: Core WebSocket implementation
- examples/websocket_demo.v: Chat room example
- examples/websocket_test.html: Browser test client
- tests/test_websocket_*.v: Property-based tests

Files modified:
- app.v: Add ws() method for WebSocket routes
- middleware.v: Export WebSocket types and functions
- picoev_server.v: WebSocket upgrade detection
- usockets_server.v: WebSocket upgrade detection
- README.md: WebSocket documentation
2026-01-04 13:38:12 +08:00
meiseayoung 4e2c6a90c3 feat: add 7 built-in middleware components inspired by Hono.js
New Middleware:
- CORS: Cross-Origin Resource Sharing with full configuration support
- Cookie Helper: Cookie management with signed cookie support (HMAC-SHA256)
- JWT: JSON Web Token authentication (HS256/384/512)
- Bearer Auth: Simple Bearer token authentication
- Compression: Gzip and deflate response compression
- Rate Limiting: Request rate limiting with customizable stores
- Validator: Schema-based request validation

Features:
- Context store for middleware data sharing (get/set/get_client_ip)
- Middleware export module with utility functions
- Comprehensive property-based tests for all middleware
- Example file demonstrating all middleware usage

Files Added:
- cors.v, cookie.v, jwt.v, bearer_auth.v, compress.v, rate_limit.v, validator.v
- middleware.v (unified exports)
- examples/middleware_demo.v
- tests/test_*.v (9 new test files)

Documentation:
- Updated README.md with detailed middleware usage guide
- Added Schema Builder API documentation
2026-01-04 11:11:15 +08:00
meiseayoung 44ba82c194 chore: update v.mod version 2026-01-03 20:50:24 +08:00
meiseayoung 9eb360926b feat(usockets): optimize high concurrency support up to 10,000+ connections
- Increase uSockets listen() backlog from 512 to 16384 in bsd.c
- Add high concurrency test suite (5000-10000 concurrent connections)
- Add HIGH_CONCURRENCY.md documentation with optimization guide
- Add usockets/build.sh script for rebuilding uSockets library
- Update README with latest benchmark results

Performance (macOS M3 Max):
- 5,000 concurrent: 81,524 RPS, 100% success
- 10,000 concurrent: 24,198 RPS, 100% success

System requirements:
- kern.ipc.somaxconn >= 8192
- ulimit -n 65535
2026-01-03 20:45:30 +08:00
meiseayoung 3b8d21dd0d fix: update import path to VPM package name (meiseayoung.hono)
- Change all `import hono` to `import meiseayoung.hono` in code and docs
- Ensure compatibility with VPM installation via `v install meiseayoung.hono`
2026-01-02 22:05:12 +08:00
meiseayoung 43aae4f10d chore: update v.mod version 2026-01-02 21:51:01 +08:00
meiseayoung 05bb2d6c95 feat: cross-platform uSockets support and route cache optimization
1. Cross-platform uSockets configuration
   - Use @VMODROOT for portable library paths
   - Support macOS (arm64/x86_64), Windows, and Linux
   - Auto-detect Homebrew paths for libuv on macOS

2. Route cache optimization (3x performance boost)
   - Replace LRU cache with FastRouteCache for dynamic routes
   - Zero-overhead cache lookup (no TTL check, no LRU reordering)
   - Dynamic route performance: 30k -> 97k RPS

3. New test files
   - Go-based integration tests for picoev and uSockets
   - FastCache optimization validation tests
   - Benchmark comparison tools
2026-01-02 21:44:07 +08:00
meiseayoung 9aefe5714d chore: update v.mod version 2025-12-31 18:35:29 +08:00
meiseayoung 1fc22044c3 perf: optimize HTTP parsing with zero-allocation techniques
- Refactor parse_path_and_query() to use single-pass traversal instead of split()
- Add eq_ignore_case() and contains_ignore_case() to avoid to_lower() allocations
- Optimize HTTP method parsing with first-char branching (parse_http_method_fast)
- Optimize Keep-Alive header check to avoid string allocations
- Apply optimizations to all server backends: picoev, usockets, and app.v

Performance improvements:
- Query string parsing: 20-44% faster
- Keep-Alive check: 82-90% faster
- HTTP method parsing: eliminates string comparison overhead
- Response header processing: removes to_lower() allocations

All 20+ test suites pass with no regressions.
2025-12-31 18:34:46 +08:00
meiseayoung b478269d8e chore: update v.mod version 2025-12-31 17:38:29 +08:00
meiseayoung 69d1385ced perf: 0 middleware ignore copy 2025-12-31 17:37:51 +08:00
meiseayoung 1482b9c4d9 doc: add Framework Comparison 2025-12-31 16:04:44 +08:00
meiseayoung 0937dd268c chore: update v.mod version 2025-12-31 15:46:10 +08:00
meiseayoung 07382ab622 feat: integration usockets backend 2025-12-31 15:44:51 +08:00
meiseayoung 5375b7394f chore: update v.mod version 2025-12-30 21:40:39 +08:00
193 changed files with 56949 additions and 557 deletions
+4 -1
View File
@@ -2,7 +2,6 @@
*.exe
*.out
*.o
*.a
*.so
*.dll
@@ -45,3 +44,7 @@ temp/
# Chunk upload temp files
chunks/
.kiro
*.def
*.dylib
+1501 -6
View File
File diff suppressed because it is too large Load Diff
+991
View File
@@ -0,0 +1,991 @@
module hono
import io
import net.http
import net.urllib
import time
import crypto.sha1
import crypto.md5
import encoding.base64
// AliyunOSS 阿里云 OSS 存储提供者
pub struct AliyunOSS {
config AliyunOSSConfig
mut:
// 用于跟踪分片上传的内存存储
multipart_uploads map[string]OSSMultipartUploadState
// 是否使用内网端点
use_internal bool
}
// OSS 分片上传状态
struct OSSMultipartUploadState {
mut:
bucket string
key string
upload_id string
content_type string
parts map[int]PartInfo
created_at i64
}
// 创建阿里云 OSS 存储提供者
pub fn new_aliyun_oss(config AliyunOSSConfig) !AliyunOSS {
// 验证配置
validation := validate_aliyun_oss_config(config)
if !validation.valid {
return error(validation.error_message)
}
return AliyunOSS{
config: config
multipart_uploads: map[string]OSSMultipartUploadState{}
use_internal: false
}
}
// 创建使用内网端点的阿里云 OSS 存储提供者
pub fn new_aliyun_oss_internal(config AliyunOSSConfig) !AliyunOSS {
// 验证配置
validation := validate_aliyun_oss_config(config)
if !validation.valid {
return error(validation.error_message)
}
if config.internal_endpoint == '' {
return error('Internal endpoint is not configured')
}
return AliyunOSS{
config: config
multipart_uploads: map[string]OSSMultipartUploadState{}
use_internal: true
}
}
// 切换到内网端点
pub fn (mut o AliyunOSS) switch_to_internal() ! {
if o.config.internal_endpoint == '' {
return error('Internal endpoint is not configured')
}
o.use_internal = true
}
// 切换到外网端点
pub fn (mut o AliyunOSS) switch_to_external() {
o.use_internal = false
}
// 获取当前使用的端点
pub fn (o AliyunOSS) get_current_endpoint() string {
if o.use_internal && o.config.internal_endpoint != '' {
return o.config.internal_endpoint
}
return o.config.endpoint
}
// ============================================================================
// 阿里云 OSS 签名算法实现 (OSS Signature V1)
// ============================================================================
// 签名请求所需的信息
struct OSSSigningInfo {
method string
content_md5 string
content_type string
date string
canonicalized_oss_headers string
canonicalized_resource string
}
// 创建签名信息
fn (o AliyunOSS) create_signing_info(method string, bucket string, key string, headers map[string]string, query_params map[string]string) OSSSigningInfo {
// 获取 Content-MD5
content_md5 := headers['Content-MD5'] or { '' }
// 获取 Content-Type
content_type := headers['Content-Type'] or { '' }
// 获取日期
date := headers['Date'] or { '' }
// 构建规范化的 OSS 头
canonicalized_oss_headers := o.build_canonicalized_oss_headers(headers)
// 构建规范化的资源
canonicalized_resource := o.build_canonicalized_resource(bucket, key, query_params)
return OSSSigningInfo{
method: method
content_md5: content_md5
content_type: content_type
date: date
canonicalized_oss_headers: canonicalized_oss_headers
canonicalized_resource: canonicalized_resource
}
}
// 构建规范化的 OSS 头
fn (o AliyunOSS) build_canonicalized_oss_headers(headers map[string]string) string {
// 收集所有以 x-oss- 开头的头
mut oss_headers := map[string]string{}
for key, value in headers {
lower_key := key.to_lower()
if lower_key.starts_with('x-oss-') {
oss_headers[lower_key] = value.trim_space()
}
}
if oss_headers.len == 0 {
return ''
}
// 按键排序
mut keys := oss_headers.keys()
keys.sort()
// 构建规范化字符串
mut result := ''
for key in keys {
result += '${key}:${oss_headers[key]}\n'
}
return result
}
// 构建规范化的资源
fn (o AliyunOSS) build_canonicalized_resource(bucket string, key string, query_params map[string]string) string {
mut resource := ''
// 添加 bucket
if bucket != '' {
resource = '/${bucket}'
}
// 添加 key
if key != '' {
resource += '/${key}'
} else if bucket != '' {
resource += '/'
} else {
resource = '/'
}
// 添加子资源参数(需要排序)
sub_resources := [
'acl', 'uploads', 'location', 'cors', 'logging', 'website', 'referer',
'lifecycle', 'delete', 'append', 'tagging', 'objectMeta', 'uploadId',
'partNumber', 'security-token', 'position', 'img', 'style', 'styleName',
'replication', 'replicationProgress', 'replicationLocation', 'cname',
'bucketInfo', 'comp', 'qos', 'live', 'status', 'vod', 'startTime',
'endTime', 'symlink', 'x-oss-process', 'response-content-type',
'response-content-language', 'response-expires', 'response-cache-control',
'response-content-disposition', 'response-content-encoding',
]
mut sub_params := []string{}
for param in sub_resources {
if param in query_params {
value := query_params[param]
if value != '' {
sub_params << '${param}=${value}'
} else {
sub_params << param
}
}
}
if sub_params.len > 0 {
resource += '?' + sub_params.join('&')
}
return resource
}
// 构建待签名字符串
fn (o AliyunOSS) build_string_to_sign(info OSSSigningInfo) string {
// 如果有 OSS 头,需要在它和资源之间加换行
if info.canonicalized_oss_headers != '' {
return '${info.method}\n${info.content_md5}\n${info.content_type}\n${info.date}\n${info.canonicalized_oss_headers}${info.canonicalized_resource}'
}
return '${info.method}\n${info.content_md5}\n${info.content_type}\n${info.date}\n${info.canonicalized_resource}'
}
// 计算签名
fn (o AliyunOSS) calculate_signature(string_to_sign string) string {
// 使用 HMAC-SHA1 计算签名
signature := oss_hmac_sha1(o.config.access_key_secret.bytes(), string_to_sign.bytes())
// Base64 编码
return base64.encode(signature)
}
// HMAC-SHA1 实现
fn oss_hmac_sha1(key []u8, data []u8) []u8 {
block_size := 64
mut k := key.clone()
// 如果 key 长度大于 block_size,先进行 hash
if k.len > block_size {
k = sha1.sum(k)
}
// 如果 key 长度小于 block_size,用 0 填充
for k.len < block_size {
k << u8(0)
}
// 计算 inner 和 outer padding
mut i_pad := []u8{len: block_size}
mut o_pad := []u8{len: block_size}
for i in 0 .. block_size {
i_pad[i] = k[i] ^ u8(0x36)
o_pad[i] = k[i] ^ u8(0x5c)
}
// inner hash: SHA1(i_pad || data)
mut inner_data := i_pad.clone()
inner_data << data
inner_hash := sha1.sum(inner_data)
// outer hash: SHA1(o_pad || inner_hash)
mut outer_data := o_pad.clone()
outer_data << inner_hash
return sha1.sum(outer_data)
}
// 签名请求并返回完整的请求头
pub fn (o AliyunOSS) sign_request(method string, bucket string, key string, mut headers map[string]string, query_params map[string]string) map[string]string {
// 添加日期头
now := time.utc()
date := o.format_http_date(now)
headers['Date'] = date
// 创建签名信息
info := o.create_signing_info(method, bucket, key, headers, query_params)
// 构建待签名字符串
string_to_sign := o.build_string_to_sign(info)
// 计算签名
signature := o.calculate_signature(string_to_sign)
// 构建 Authorization 头
headers['Authorization'] = 'OSS ${o.config.access_key_id}:${signature}'
return headers
}
// 格式化 HTTP 日期
fn (o AliyunOSS) format_http_date(t time.Time) string {
// HTTP 日期格式: "Mon, 02 Jan 2006 15:04:05 GMT"
days := ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
months := ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
day_of_week := days[int(t.day_of_week())]
month := months[t.month - 1]
return '${day_of_week}, ${t.day:02d} ${month} ${t.year} ${t.hour:02d}:${t.minute:02d}:${t.second:02d} GMT'
}
// ============================================================================
// HTTP 请求辅助方法
// ============================================================================
// 获取主机名
fn (o AliyunOSS) get_host(bucket string) string {
endpoint := o.get_current_endpoint()
if bucket != '' {
return '${bucket}.${endpoint}'
}
return endpoint
}
// 获取完整 URL
fn (o AliyunOSS) get_url(bucket string, key string, query_params map[string]string) string {
host := o.get_host(bucket)
mut path := ''
if key != '' {
// URL 编码 key,但保留 /
path = '/' + o.encode_key(key)
} else {
path = '/'
}
mut url := 'https://${host}${path}'
if query_params.len > 0 {
query_string := o.build_query_string(query_params)
url += '?${query_string}'
}
return url
}
// URL 编码 key(保留 /)
fn (o AliyunOSS) encode_key(key string) string {
parts := key.split('/')
mut encoded_parts := []string{}
for part in parts {
encoded_parts << urllib.query_escape(part)
}
return encoded_parts.join('/')
}
// 构建查询字符串
fn (o AliyunOSS) build_query_string(params map[string]string) string {
if params.len == 0 {
return ''
}
mut keys := params.keys()
keys.sort()
mut parts := []string{}
for key in keys {
value := params[key]
if value != '' {
parts << '${urllib.query_escape(key)}=${urllib.query_escape(value)}'
} else {
parts << urllib.query_escape(key)
}
}
return parts.join('&')
}
// 执行 HTTP 请求
fn (o AliyunOSS) do_request(method http.Method, bucket string, key string, query_params map[string]string, extra_headers map[string]string, payload []u8) !http.Response {
url := o.get_url(bucket, key, query_params)
// 准备请求头
mut headers := extra_headers.clone()
headers['Host'] = o.get_host(bucket)
// 如果有 payload,计算 Content-MD5
if payload.len > 0 {
md5_hash := md5.sum(payload)
headers['Content-MD5'] = base64.encode(md5_hash)
headers['Content-Length'] = payload.len.str()
}
// 签名请求
signed_headers := o.sign_request(method.str(), bucket, key, mut headers, query_params)
// 构建 http.Header
mut http_header := http.Header{}
for k, v in signed_headers {
http_header.add_custom(k, v) or {}
}
// 执行请求
mut config := http.FetchConfig{
url: url
method: method
header: http_header
data: payload.bytestr()
}
response := http.fetch(config) or {
return error(new_timeout_error('aliyun_oss', method.str()).msg())
}
return response
}
// 解析 OSS 错误响应
fn (o AliyunOSS) parse_error_response(response http.Response, operation string) StorageError {
status := response.status_code
// 根据状态码判断错误类型
kind := match status {
403 { StorageErrorKind.access_denied }
404 { StorageErrorKind.object_not_found }
409 { StorageErrorKind.bucket_not_found }
429 { StorageErrorKind.rate_limited }
500, 502, 503, 504 { StorageErrorKind.service_unavailable }
else { StorageErrorKind.unknown }
}
// 尝试从响应体解析错误消息
message := if response.body.len > 0 {
o.extract_error_message(response.body)
} else {
'HTTP ${status}'
}
return new_storage_error_with_status(kind, message, 'aliyun_oss', operation, status)
}
// 从 XML 错误响应中提取错误消息
fn (o AliyunOSS) extract_error_message(body string) string {
// 简单的 XML 解析,提取 <Message> 标签内容
if message_start := body.index('<Message>') {
if message_end := body.index('</Message>') {
return body[message_start + 9..message_end]
}
}
// 尝试提取 <Code> 标签
if code_start := body.index('<Code>') {
if code_end := body.index('</Code>') {
return body[code_start + 6..code_end]
}
}
return body
}
// 计算数据的 ETag (MD5)
fn (o AliyunOSS) calculate_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
// ============================================================================
// StorageProvider 接口实现 - 基本操作
// ============================================================================
// 上传文件
pub fn (mut o AliyunOSS) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
mut headers := map[string]string{}
if content_type != '' {
headers['Content-Type'] = content_type
} else {
headers['Content-Type'] = 'application/octet-stream'
}
response := o.do_request(.put, bucket, key, map[string]string{}, headers, data)!
if response.status_code != 200 {
return error(o.parse_error_response(response, 'upload').msg())
}
// 从响应头获取 ETag
etag := response.header.get_custom('ETag') or { o.calculate_etag(data) }
return new_storage_result(key, etag, i64(data.len))
}
// 流式上传文件
pub fn (mut o AliyunOSS) upload_stream(bucket string, key string, mut reader io.Reader, size i64, content_type string) !StorageResult {
// 读取所有数据
mut data := []u8{}
mut buf := []u8{len: 8192}
for {
n := reader.read(mut buf) or { break }
if n == 0 {
break
}
data << buf[..n]
}
return o.upload(bucket, key, data, content_type)
}
// 下载文件
pub fn (o AliyunOSS) download(bucket string, key string) ![]u8 {
response := o.do_request(.get, bucket, key, map[string]string{}, map[string]string{}, []u8{})!
if response.status_code == 404 {
return error(new_not_found_error('aliyun_oss', bucket, key).msg())
}
if response.status_code != 200 {
return error(o.parse_error_response(response, 'download').msg())
}
return response.body.bytes()
}
// 流式下载文件
pub fn (o AliyunOSS) download_stream(bucket string, key string, mut writer io.Writer) !i64 {
data := o.download(bucket, key)!
written := writer.write(data) or {
return error(new_storage_error(.unknown, 'Failed to write to stream: ${err}', 'aliyun_oss', 'download_stream').msg())
}
return i64(written)
}
// 删除文件
pub fn (o AliyunOSS) delete(bucket string, key string) ! {
response := o.do_request(.delete, bucket, key, map[string]string{}, map[string]string{}, []u8{})!
// OSS 返回 204 或 200 表示成功删除
if response.status_code != 204 && response.status_code != 200 {
if response.status_code == 404 {
return error(new_not_found_error('aliyun_oss', bucket, key).msg())
}
return error(o.parse_error_response(response, 'delete').msg())
}
}
// 检查文件是否存在
pub fn (o AliyunOSS) exists(bucket string, key string) !bool {
response := o.do_request(.head, bucket, key, map[string]string{}, map[string]string{}, []u8{})!
if response.status_code == 200 {
return true
}
if response.status_code == 404 {
return false
}
return error(o.parse_error_response(response, 'exists').msg())
}
// ============================================================================
// StorageProvider 接口实现 - 元数据操作
// ============================================================================
// 获取文件元数据
pub fn (o AliyunOSS) head(bucket string, key string) !ObjectInfo {
response := o.do_request(.head, bucket, key, map[string]string{}, map[string]string{}, []u8{})!
if response.status_code == 404 {
return error(new_not_found_error('aliyun_oss', bucket, key).msg())
}
if response.status_code != 200 {
return error(o.parse_error_response(response, 'head').msg())
}
// 解析响应头
content_length := response.header.get_custom('Content-Length') or { '0' }
etag := response.header.get_custom('ETag') or { '' }
content_type := response.header.get_custom('Content-Type') or { 'application/octet-stream' }
last_modified_str := response.header.get_custom('Last-Modified') or { '' }
// 解析 Last-Modified 时间
last_modified := parse_oss_http_date(last_modified_str)
return new_object_info(key, content_length.i64(), etag, content_type, last_modified)
}
// 复制文件
pub fn (mut o AliyunOSS) copy(src_bucket string, src_key string, dst_bucket string, dst_key string) !StorageResult {
mut headers := map[string]string{}
// OSS 复制源格式
copy_source := '/${src_bucket}/${src_key}'
headers['x-oss-copy-source'] = copy_source
response := o.do_request(.put, dst_bucket, dst_key, map[string]string{}, headers, []u8{})!
if response.status_code != 200 {
return error(o.parse_error_response(response, 'copy').msg())
}
// 从响应解析 ETag
etag := o.extract_copy_result_etag(response.body)
// 获取复制后文件的大小
head_info := o.head(dst_bucket, dst_key) or {
return new_storage_result(dst_key, etag, 0)
}
return new_storage_result(dst_key, etag, head_info.size)
}
// 从复制结果 XML 中提取 ETag
fn (o AliyunOSS) extract_copy_result_etag(body string) string {
if etag_start := body.index('<ETag>') {
if etag_end := body.index('</ETag>') {
return body[etag_start + 6..etag_end]
}
}
return ''
}
// ============================================================================
// StorageProvider 接口实现 - 列表操作
// ============================================================================
// 列出文件
pub fn (o AliyunOSS) list(bucket string, options ListOptions) !ListResult {
mut query_params := map[string]string{}
query_params['list-type'] = '2' // 使用 ListObjectsV2
if options.prefix != '' {
query_params['prefix'] = options.prefix
}
if options.delimiter != '' {
query_params['delimiter'] = options.delimiter
}
if options.max_keys > 0 {
query_params['max-keys'] = options.max_keys.str()
}
if options.start_after != '' {
query_params['start-after'] = options.start_after
}
response := o.do_request(.get, bucket, '', query_params, map[string]string{}, []u8{})!
if response.status_code == 404 {
return error(new_bucket_not_found_error('aliyun_oss', bucket).msg())
}
if response.status_code != 200 {
return error(o.parse_error_response(response, 'list').msg())
}
// 解析 XML 响应
return o.parse_list_objects_response(response.body)
}
// 解析 ListObjectsV2 响应
fn (o AliyunOSS) parse_list_objects_response(body string) !ListResult {
mut objects := []ObjectInfo{}
mut common_prefixes := []string{}
mut is_truncated := false
mut next_marker := ''
// 解析 IsTruncated
if truncated_start := body.index('<IsTruncated>') {
if truncated_end := body.index('</IsTruncated>') {
truncated_str := body[truncated_start + 13..truncated_end]
is_truncated = truncated_str == 'true'
}
}
// 解析 NextContinuationToken
if token_start := body.index('<NextContinuationToken>') {
if token_end := body.index('</NextContinuationToken>') {
next_marker = body[token_start + 23..token_end]
}
}
// 解析 Contents
mut search_pos := 0
for {
content_start := body.index_after('<Contents>', search_pos) or { break }
content_end := body.index_after('</Contents>', content_start) or { break }
content_xml := body[content_start..content_end + 11]
obj := o.parse_object_from_xml(content_xml)
objects << obj
search_pos = content_end + 11
}
// 解析 CommonPrefixes
search_pos = 0
for {
prefix_start := body.index_after('<CommonPrefixes>', search_pos) or { break }
prefix_end := body.index_after('</CommonPrefixes>', prefix_start) or { break }
prefix_xml := body[prefix_start..prefix_end + 17]
if p_start := prefix_xml.index('<Prefix>') {
if p_end := prefix_xml.index('</Prefix>') {
common_prefixes << prefix_xml[p_start + 8..p_end]
}
}
search_pos = prefix_end + 17
}
return new_list_result(objects, common_prefixes, is_truncated, next_marker)
}
// 从 XML 解析单个对象信息
fn (o AliyunOSS) parse_object_from_xml(xml string) ObjectInfo {
mut key := ''
mut size := i64(0)
mut etag := ''
mut last_modified := i64(0)
if key_start := xml.index('<Key>') {
if key_end := xml.index('</Key>') {
key = xml[key_start + 5..key_end]
}
}
if size_start := xml.index('<Size>') {
if size_end := xml.index('</Size>') {
size = xml[size_start + 6..size_end].i64()
}
}
if etag_start := xml.index('<ETag>') {
if etag_end := xml.index('</ETag>') {
etag = xml[etag_start + 6..etag_end]
}
}
if lm_start := xml.index('<LastModified>') {
if lm_end := xml.index('</LastModified>') {
lm_str := xml[lm_start + 14..lm_end]
last_modified = parse_oss_iso8601_date(lm_str)
}
}
content_type := infer_content_type(key)
return new_object_info(key, size, etag, content_type, last_modified)
}
// ============================================================================
// StorageProvider 接口实现 - Bucket 操作
// ============================================================================
// 创建 bucket
pub fn (o AliyunOSS) create_bucket(bucket string) ! {
mut headers := map[string]string{}
headers['Content-Type'] = 'application/xml'
// 构建创建 bucket 的 XML
// 注意:OSS 需要指定存储类型和数据冗余类型
payload := '<?xml version="1.0" encoding="UTF-8"?><CreateBucketConfiguration><StorageClass>Standard</StorageClass></CreateBucketConfiguration>'
response := o.do_request(.put, bucket, '', map[string]string{}, headers, payload.bytes())!
// 200 或 409 (BucketAlreadyExists) 都算成功
if response.status_code != 200 && response.status_code != 409 {
return error(o.parse_error_response(response, 'create_bucket').msg())
}
}
// 删除 bucket
pub fn (o AliyunOSS) delete_bucket(bucket string) ! {
response := o.do_request(.delete, bucket, '', map[string]string{}, map[string]string{}, []u8{})!
if response.status_code != 204 && response.status_code != 200 {
if response.status_code == 404 {
return error(new_bucket_not_found_error('aliyun_oss', bucket).msg())
}
return error(o.parse_error_response(response, 'delete_bucket').msg())
}
}
// 检查 bucket 是否存在
pub fn (o AliyunOSS) bucket_exists(bucket string) !bool {
response := o.do_request(.head, bucket, '', map[string]string{}, map[string]string{}, []u8{})!
if response.status_code == 200 {
return true
}
if response.status_code == 404 {
return false
}
return error(o.parse_error_response(response, 'bucket_exists').msg())
}
// 获取提供者名称
pub fn (o AliyunOSS) provider_name() string {
return 'aliyun_oss'
}
// ============================================================================
// StorageProvider 接口实现 - 分片上传
// ============================================================================
// 初始化分片上传
pub fn (mut o AliyunOSS) init_multipart(bucket string, key string, content_type string) !string {
mut query_params := map[string]string{}
query_params['uploads'] = ''
mut headers := map[string]string{}
if content_type != '' {
headers['Content-Type'] = content_type
}
response := o.do_request(.post, bucket, key, query_params, headers, []u8{})!
if response.status_code != 200 {
return error(o.parse_error_response(response, 'init_multipart').msg())
}
// 从 XML 响应中提取 UploadId
upload_id := o.extract_upload_id(response.body)
if upload_id == '' {
return error(new_storage_error(.unknown, 'Failed to parse UploadId from response', 'aliyun_oss', 'init_multipart').msg())
}
// 记录上传状态
o.multipart_uploads[upload_id] = OSSMultipartUploadState{
bucket: bucket
key: key
upload_id: upload_id
content_type: content_type
parts: map[int]PartInfo{}
created_at: time.now().unix()
}
return upload_id
}
// 从 InitiateMultipartUploadResult XML 中提取 UploadId
fn (o AliyunOSS) extract_upload_id(body string) string {
if id_start := body.index('<UploadId>') {
if id_end := body.index('</UploadId>') {
return body[id_start + 10..id_end]
}
}
return ''
}
// 上传分片
pub fn (mut o AliyunOSS) upload_part(bucket string, key string, upload_id string, part_number int, data []u8) !string {
mut query_params := map[string]string{}
query_params['partNumber'] = part_number.str()
query_params['uploadId'] = upload_id
mut headers := map[string]string{}
headers['Content-Length'] = data.len.str()
response := o.do_request(.put, bucket, key, query_params, headers, data)!
if response.status_code != 200 {
return error(o.parse_error_response(response, 'upload_part').msg())
}
// 从响应头获取 ETag
etag := response.header.get_custom('ETag') or { o.calculate_etag(data) }
// 更新上传状态
if upload_id in o.multipart_uploads {
mut upload_state := o.multipart_uploads[upload_id]
upload_state.parts[part_number] = new_part_info(part_number, etag, i64(data.len))
o.multipart_uploads[upload_id] = upload_state
}
return etag
}
// 完成分片上传
pub fn (mut o AliyunOSS) complete_multipart(bucket string, key string, upload_id string, parts []PartInfo) !StorageResult {
mut query_params := map[string]string{}
query_params['uploadId'] = upload_id
// 构建 CompleteMultipartUpload XML
mut xml_parts := '<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>'
for part in parts {
xml_parts += '<Part>'
xml_parts += '<PartNumber>${part.part_number}</PartNumber>'
xml_parts += '<ETag>${part.etag}</ETag>'
xml_parts += '</Part>'
}
xml_parts += '</CompleteMultipartUpload>'
mut headers := map[string]string{}
headers['Content-Type'] = 'application/xml'
payload := xml_parts.bytes()
response := o.do_request(.post, bucket, key, query_params, headers, payload)!
if response.status_code != 200 {
return error(o.parse_error_response(response, 'complete_multipart').msg())
}
// 从响应解析 ETag
etag := o.extract_complete_multipart_etag(response.body)
// 计算总大小
mut total_size := i64(0)
for part in parts {
total_size += part.size
}
// 清理上传状态
o.multipart_uploads.delete(upload_id)
return new_storage_result(key, etag, total_size)
}
// 从 CompleteMultipartUploadResult XML 中提取 ETag
fn (o AliyunOSS) extract_complete_multipart_etag(body string) string {
if etag_start := body.index('<ETag>') {
if etag_end := body.index('</ETag>') {
return body[etag_start + 6..etag_end]
}
}
return ''
}
// 取消分片上传
pub fn (mut o AliyunOSS) abort_multipart(bucket string, key string, upload_id string) ! {
mut query_params := map[string]string{}
query_params['uploadId'] = upload_id
response := o.do_request(.delete, bucket, key, query_params, map[string]string{}, []u8{})!
if response.status_code != 204 && response.status_code != 200 {
return error(o.parse_error_response(response, 'abort_multipart').msg())
}
// 清理上传状态
o.multipart_uploads.delete(upload_id)
}
// ============================================================================
// StorageProvider 接口实现 - 预签名 URL
// ============================================================================
// 生成预签名 URL
pub fn (o AliyunOSS) presign_url(bucket string, key string, options PresignOptions) !string {
// 计算过期时间戳
now := time.utc()
expires := now.unix() + i64(options.expires_in)
// 构建待签名字符串
// 格式: METHOD\n\nContent-Type\nExpires\nCanonicalizedOSSHeaders\nCanonicalizedResource
mut content_type := ''
if options.content_type != '' {
content_type = options.content_type
}
canonicalized_resource := o.build_canonicalized_resource(bucket, key, map[string]string{})
string_to_sign := '${options.method}\n\n${content_type}\n${expires}\n${canonicalized_resource}'
// 计算签名
signature := o.calculate_signature(string_to_sign)
// 构建 URL
host := o.get_host(bucket)
encoded_key := o.encode_key(key)
mut url := 'https://${host}/${encoded_key}'
url += '?OSSAccessKeyId=${urllib.query_escape(o.config.access_key_id)}'
url += '&Expires=${expires}'
url += '&Signature=${urllib.query_escape(signature)}'
return url
}
// ============================================================================
// 辅助函数
// ============================================================================
// 解析 OSS HTTP 日期格式
fn parse_oss_http_date(date_str string) i64 {
if date_str == '' {
return 0
}
// HTTP 日期格式: "Mon, 02 Jan 2006 15:04:05 GMT"
// 简化处理,返回当前时间
return time.now().unix()
}
// 解析 OSS ISO8601 日期格式
fn parse_oss_iso8601_date(date_str string) i64 {
if date_str == '' {
return 0
}
// ISO8601 格式: "2006-01-02T15:04:05.000Z"
// 简化处理,返回当前时间
return time.now().unix()
}
+176 -16
View File
@@ -40,6 +40,10 @@ pub mut:
use_fast_router bool = true // 新增:是否使用快速路由器
context_middlewares []ContextMiddleware
route_middlewares map[string][]ContextMiddleware // 路由前缀对应的中间件
// 优化:预排序的中间件前缀列表(启动时计算一次)
sorted_middleware_prefixes []string
// 优化:标记是否有中间件(用于零中间件快速路径)
has_middlewares bool
}
// Context 中间件类型
@@ -171,6 +175,35 @@ pub fn (mut app Hono) options(path string, handler fn (mut Context) http.Respons
app.context_trie_router.add_route('OPTIONS', path, h)
}
// ws() - Register a WebSocket route
// This method registers a WebSocket upgrade handler at the specified path.
// The factory function receives the HTTP Context and returns WSEvents configuration.
//
// Parameters:
// path: The route path (supports path parameters like /ws/:room/:id)
// factory: A function that receives the HTTP Context and returns WSEvents
// options: Optional WebSocket configuration
//
// Example:
// app.ws('/chat/:room', fn (c Context) WSEvents {
// room := c.params['room'] or { 'default' }
// return WSEvents{
// on_open: fn (mut ws WSContext) {
// ws.send('Welcome to room!') or {}
// }
// on_message: fn (event WSMessageEvent, mut ws WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// }
// })
pub fn (mut app Hono) ws(path string, factory WSHandlerFactory, options ...WebSocketOptions) {
// Create the WebSocket upgrade handler
ws_handler := upgrade_websocket(factory, ...options)
// Register as a GET route (WebSocket upgrades use GET method)
app.get(path, ws_handler)
}
// all() 方法 - 为所有 HTTP 方法注册同一个处理器
pub fn (mut app Hono) all(path string, handler fn (mut Context) http.Response) {
app.get(path, handler)
@@ -185,6 +218,14 @@ pub fn (mut app Hono) all(path string, handler fn (mut Context) http.Response) {
// Context 中间件
pub fn (mut app Hono) use(mw ContextMiddleware) {
app.context_middlewares << mw
app.has_middlewares = true
}
// 预计算中间件前缀排序(在服务器启动前调用)
pub fn (mut app Hono) precompute_middleware_prefixes() {
app.sorted_middleware_prefixes = app.route_middlewares.keys()
app.sorted_middleware_prefixes.sort(a.len < b.len)
app.has_middlewares = app.context_middlewares.len > 0 || app.route_middlewares.len > 0
}
// notFound() - 自定义 404 处理器
@@ -215,41 +256,53 @@ fn (mut s ServerHanler) handle(req http.Request) http.Response {
}
}
// 解析 query
raw_query := url.query().to_map()
// 解析 query - 优化:直接使用 url.raw_query 避免额外解析
mut query_map := map[string]string{}
for key, values in raw_query {
if values.len > 0 {
query_map[key] = values[0]
}
if url.raw_query.len > 0 {
query_map = parse_query_string_app(url.raw_query)
}
// 缓存 method 字符串,避免多次调用 .str()
method_str := req.method.str()
// 尝试 Context 路由
// 优先使用快速路由器
if s.app.use_fast_router {
if route_match := s.app.fast_router.match_route(req.method.str(), url.path) {
if route_match := s.app.fast_router.match_route(method_str, url.path) {
// param 由路由匹配结果提供
param_map := route_match.params.clone()
// body
body := req.data
// 构造 Context
mut ctx := Context.new(req, param_map, query_map, body)
// 优化:零中间件快速路径
if !s.app.has_middlewares {
return route_match.handler.handle(mut ctx)
}
// 获取该路径对应的中间件
middlewares := s.get_middlewares_for_path(url.path)
// 洋葱模型递归执行中间件
return s.exec_context_middlewares_with_list(0, middlewares, mut ctx, fn [route_match] (mut c Context) http.Response {
return route_match.handler.handle(mut c)
})
})
}
// 如果快速路由器没有匹配,回退到混合路由器
if route_match := s.app.context_hybrid_router.match_route(req.method.str(), url.path) {
if route_match := s.app.context_hybrid_router.match_route(method_str, url.path) {
// param 由路由匹配结果提供
param_map := route_match.params.clone()
// body
body := req.data
// 构造 Context
mut ctx := Context.new(req, param_map, query_map, body)
// 优化:零中间件快速路径
if !s.app.has_middlewares {
return route_match.handler.handle(mut ctx)
}
// 获取该路径对应的中间件
middlewares := s.get_middlewares_for_path(url.path)
// 洋葱模型递归执行中间件
@@ -258,13 +311,19 @@ fn (mut s ServerHanler) handle(req http.Request) http.Response {
})
}
} else {
if route_match := s.app.context_hybrid_router.match_route(req.method.str(), url.path) {
if route_match := s.app.context_hybrid_router.match_route(method_str, url.path) {
// param 由路由匹配结果提供
param_map := route_match.params.clone()
// body
body := req.data
// 构造 Context
mut ctx := Context.new(req, param_map, query_map, body)
// 优化:零中间件快速路径
if !s.app.has_middlewares {
return route_match.handler.handle(mut ctx)
}
// 获取该路径对应的中间件
middlewares := s.get_middlewares_for_path(url.path)
// 洋葱模型递归执行中间件
@@ -291,15 +350,17 @@ fn (mut s ServerHanler) handle(req http.Request) http.Response {
})
}
// 获取路径对应的所有中间件(全局 + 路由前缀匹配的)
// 获取路径对应的所有中间件(全局 + 路由前缀匹配的)- 优化版
fn (s ServerHanler) get_middlewares_for_path(path string) []ContextMiddleware {
// 优化:只有全局中间件时,直接返回引用(避免克隆)
if s.app.route_middlewares.len == 0 {
return s.app.context_middlewares
}
mut middlewares := s.app.context_middlewares.clone()
// 按前缀长度排序,确保更具体的前缀后执行
mut prefixes := s.app.route_middlewares.keys()
prefixes.sort(a.len < b.len)
for prefix in prefixes {
// 优化:使用预排序的前缀列表(启动时已排序,不需要每次请求都排序)
for prefix in s.app.sorted_middleware_prefixes {
if path.starts_with(prefix) || prefix == '/' {
if mws := s.app.route_middlewares[prefix] {
middlewares << mws
@@ -342,6 +403,9 @@ pub fn (mut app Hono) listen(port string) {
return
}
// 优化:预计算中间件前缀排序
app.precompute_middleware_prefixes()
// 使用优化配置的 picoev 高性能服务器(支持高并发)
app.listen_picoev_with_config(PicoevConfig{
port: port_num
@@ -372,6 +436,7 @@ pub fn (mut app Hono) route(prefix string, mut subapp Hono) {
} else {
app.route_middlewares[prefix] = subapp.context_middlewares.clone()
}
app.has_middlewares = true
}
// 继承子应用的 notFound 和 onError 处理器(如果主应用没有设置)
@@ -508,5 +573,100 @@ pub fn Hono.new() Hono {
fast_router: FastRouter.new()
use_fast_router: true
route_middlewares: map[string][]ContextMiddleware{}
sorted_middleware_prefixes: []string{}
has_middlewares: false
}
}
// ============================================================================
// 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 {
mut query_map := map[string]string{}
len := query_str.len
if len == 0 {
return query_map
}
mut key_start := 0
mut key_end := -1
mut value_start := -1
for i := 0; i <= len; i++ {
ch := if i < len { query_str[i] } else { `&` } // 末尾视为分隔符
if ch == `=` && key_end == -1 {
key_end = i
value_start = i + 1
} else if ch == `&` {
// 完成一个键值对
if key_end > key_start && value_start > 0 {
key := query_str[key_start..key_end]
value := if value_start < i { query_str[value_start..i] } else { '' }
query_map[key] = value
} else if key_end == -1 && i > key_start {
// 只有 key 没有 value
key := query_str[key_start..i]
query_map[key] = ''
}
// 重置状态
key_start = i + 1
key_end = -1
value_start = -1
}
}
return query_map
}
+137
View File
@@ -0,0 +1,137 @@
module hono
import net.http
// BearerToken 类型 - 支持单 token 或多 token 配置
pub type BearerToken = string | []string
// BearerAuthOptions 结构体 - Bearer Auth 配置选项
pub struct BearerAuthOptions {
pub:
token BearerToken // Token 配置(必需)
realm string // WWW-Authenticate realm
prefix string = 'Bearer' // 认证前缀,默认 "Bearer"
header_name string = 'Authorization' // 请求头名称,默认 "Authorization"
hash_function ?fn (string) string // 哈希函数(用于安全比较)
verify_token ?fn (string, Context) bool // 自定义验证回调
}
// bearer_auth - Bearer Auth 中间件工厂函数
// 返回一个 ContextMiddleware,用于验证 Bearer Token
pub fn bearer_auth(options BearerAuthOptions) ContextMiddleware {
return fn [options] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 获取 Authorization 头
auth_header := c.req.header.get_custom(options.header_name) or {
return unauthorized_response(mut c, options.realm, 'Missing authorization header')
}
if auth_header.len == 0 {
return unauthorized_response(mut c, options.realm, 'Missing authorization header')
}
// 解析 token
token := extract_bearer_token(auth_header, options.prefix) or {
return unauthorized_response(mut c, options.realm, 'Invalid token format')
}
// 验证 token
if !validate_bearer_token(token, options, c) {
return unauthorized_response(mut c, options.realm, 'Invalid token')
}
// 将 token 存储到 Context(便于后续使用)
c.set('bearer_token', token)
// 继续处理请求
return next(mut c)
}
}
// extract_bearer_token - 从 Authorization 头提取 token
fn extract_bearer_token(auth_header string, prefix string) !string {
expected_prefix := '${prefix} '
if !auth_header.starts_with(expected_prefix) {
return error('Invalid authorization format')
}
token := auth_header[expected_prefix.len..].trim_space()
if token.len == 0 {
return error('Empty token')
}
return token
}
// validate_bearer_token - 验证 Bearer Token
fn validate_bearer_token(token string, options BearerAuthOptions, c Context) bool {
// 优先使用自定义验证回调
if verify_fn := options.verify_token {
return verify_fn(token, c)
}
// 使用配置的 token 进行验证
match options.token {
string {
// 单 token 验证
return secure_token_compare(token, options.token, options.hash_function)
}
[]string {
// 多 token 验证
for valid_token in options.token {
if secure_token_compare(token, valid_token, options.hash_function) {
return true
}
}
return false
}
}
}
// secure_token_compare - 安全的 token 比较(防止时序攻击)
fn secure_token_compare(provided string, expected string, hash_fn ?fn (string) string) bool {
// 如果提供了哈希函数,先对 token 进行哈希
if hash_function := hash_fn {
hashed_provided := hash_function(provided)
hashed_expected := hash_function(expected)
return constant_time_compare_bearer(hashed_provided, hashed_expected)
}
// 直接使用常量时间比较
return constant_time_compare_bearer(provided, expected)
}
// constant_time_compare_bearer - 常量时间字符串比较,防止时序攻击
fn constant_time_compare_bearer(a string, b string) bool {
if a.len != b.len {
return false
}
mut result := u8(0)
for i := 0; i < a.len; i++ {
result |= a[i] ^ b[i]
}
return result == 0
}
// unauthorized_response - 返回 401 未授权响应
fn unauthorized_response(mut c Context, realm string, message string) http.Response {
c.status(401)
// 设置 WWW-Authenticate 头
mut www_auth := 'Bearer'
if realm.len > 0 {
www_auth = 'Bearer realm="${realm}"'
}
c.headers['WWW-Authenticate'] = www_auth
return c.json('{"error":"Unauthorized","message":"${message}"}')
}
// get_bearer_token - 从 Context 获取已验证的 Bearer Token
// 这是一个便捷方法,用于在 handler 中获取已验证的 token
pub fn get_bearer_token(c Context) ?string {
return c.get('bearer_token')
}
+77
View File
@@ -0,0 +1,77 @@
# V-Hono Benchmark
Performance benchmarks for V-Hono web framework.
## Framework Comparison (500 connections, 100K requests)
| Framework | RPS | Errors | Avg Latency | P50 | P95 | P99 |
|-----------|-----|--------|-------------|-----|-----|-----|
| **v-hono (uSockets)** | **20,231** | **0** | **24.62ms** | **23.65ms** | **33.82ms** | **57.29ms** |
| v-hono (picoev) | 16,177 | 0 | 30.75ms | 26.25ms | 35.37ms | 111.38ms |
| veb (V官方) | 7,278 | 772 | 68.06ms | 55.79ms | 71.62ms | 98.90ms |
### Performance Summary
- **v-hono (uSockets) vs veb**: **2.78x faster**, zero errors
- **v-hono (uSockets) vs picoev**: **25% faster**, better P99 latency
- **v-hono (picoev) vs veb**: **2.22x faster**, zero errors
## Running Benchmarks
### 1. Start Test Server
**uSockets backend:**
```bash
v -enable-globals -cc gcc -ldflags "-ldbghelp" benchmark/usockets_server.v -o server.exe
./server.exe
```
**Picoev backend:**
```bash
v -enable-globals examples/picoev_example.v -o server.exe
./server.exe
```
### 2. Run Integration Tests
```bash
go run benchmark/usockets_verify.go
```
### 3. Run Performance Tests
```bash
# 200 connections, 100K requests
go run tests/main.go -url "http://127.0.0.1:9998" -c 200 -n 100000
# 500 connections, 100K requests
go run tests/main.go -url "http://127.0.0.1:9998" -c 500 -n 100000
```
### 4. Quick Concurrent Test (PowerShell)
```powershell
powershell -File benchmark/quick_test.ps1
```
## Test Files
| File | Description |
|------|-------------|
| `usockets_server.v` | uSockets test server with full API |
| `usockets_verify.go` | Go integration test (17 test cases) |
| `quick_test.ps1` | PowerShell concurrent test script |
| `../tests/main.go` | Go performance benchmark tool |
## Test Coverage
The integration test (`usockets_verify.go`) covers:
- ✅ Basic GET routes (root, health, static)
- ✅ Dynamic routes (single/multi/nested params)
- ✅ Query parameters
- ✅ Response formats (JSON, HTML, custom status)
- ✅ HTTP methods (POST, PUT, DELETE)
- ✅ Error handling (404)
- ✅ Keep-Alive connections
- ✅ Throughput performance
+121
View File
@@ -0,0 +1,121 @@
// uSockets 测试服务器
// 用于集成测试验证 uSockets 后端功能
//
// 编译运行:
// v -enable-globals -cc gcc -ldflags "-ldbghelp" benchmark/usockets_test_server.v -o usockets_test.exe
// .\usockets_test.exe
module main
import meiseayoung.hono
import net.http
fn main() {
mut app := hono.Hono.new()
// 全局中间件 - 添加自定义响应头
app.use(fn (mut c hono.Context, next fn (mut hono.Context) http.Response) http.Response {
mut resp := next(mut c)
c.headers['X-Middleware'] = 'applied'
return resp
})
// ==================== 基本路由 ====================
app.get('/', fn (mut c hono.Context) http.Response {
return c.text('Hello World')
})
app.get('/health', fn (mut c hono.Context) http.Response {
return c.text('OK')
})
app.get('/api/health', fn (mut c hono.Context) http.Response {
return c.text('OK')
})
// ==================== CRUD 路由 ====================
app.get('/api/users', fn (mut c hono.Context) http.Response {
return c.json('{"users": []}')
})
app.post('/api/users', fn (mut c hono.Context) http.Response {
c.status(201)
return c.json('{"created": true}')
})
app.get('/api/users/:id', fn (mut c hono.Context) http.Response {
id := c.params['id'] or { '' }
return c.json('{"id": "${id}"}')
})
app.put('/api/users/:id', fn (mut c hono.Context) http.Response {
id := c.params['id'] or { '' }
return c.json('{"id": "${id}", "updated": true}')
})
app.delete('/api/users/:id', fn (mut c hono.Context) http.Response {
id := c.params['id'] or { '' }
return c.json('{"id": "${id}", "deleted": true}')
})
app.patch('/api/users/:id', fn (mut c hono.Context) http.Response {
id := c.params['id'] or { '' }
return c.json('{"id": "${id}", "patched": true}')
})
// ==================== 多参数路由 ====================
app.get('/api/users/:user_id/posts/:post_id', fn (mut c hono.Context) http.Response {
user_id := c.params['user_id'] or { '' }
post_id := c.params['post_id'] or { '' }
return c.json('{"user_id": "${user_id}", "post_id": "${post_id}"}')
})
app.get('/api/categories/:category/items/:item', fn (mut c hono.Context) http.Response {
category := c.params['category'] or { '' }
item := c.params['item'] or { '' }
return c.json('{"category": "${category}", "item": "${item}"}')
})
// ==================== 查询参数路由 ====================
app.get('/api/search', fn (mut c hono.Context) http.Response {
q := c.query['q'] or { '' }
limit := c.query['limit'] or { '10' }
page := c.query['page'] or { '1' }
return c.json('{"query": "${q}", "limit": ${limit}, "page": ${page}}')
})
// ==================== 响应格式路由 ====================
app.get('/api/json', fn (mut c hono.Context) http.Response {
return c.json('{"message": "JSON response"}')
})
app.get('/api/html', fn (mut c hono.Context) http.Response {
return c.html('<html><body><h1>HTML Response</h1></body></html>')
})
app.get('/api/created', fn (mut c hono.Context) http.Response {
c.status(201)
return c.json('{"created": true}')
})
app.get('/api/custom-header', fn (mut c hono.Context) http.Response {
c.headers['X-Custom-Header'] = 'custom-value'
return c.text('OK')
})
// ==================== 自定义 404 ====================
app.not_found(fn (mut c hono.Context) http.Response {
c.status(404)
return c.json('{"error": "Not Found", "path": "${c.path}"}')
})
// 启动 uSockets 服务器
println('[usockets-test-server] Starting on port 9998...')
app.listen_usockets(9998)
}
+496
View File
@@ -0,0 +1,496 @@
// uSockets 功能验证测试
// go run benchmark/usockets_verify.go
package main
import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
const baseURL = "http://127.0.0.1:9998"
func main() {
fmt.Println("╔═══════════════════════════════════════════════════════════════╗")
fmt.Println("║ uSockets 集成测试 ║")
fmt.Println("╚═══════════════════════════════════════════════════════════════╝")
fmt.Println()
client := &http.Client{Timeout: 5 * time.Second}
// 检查服务器
fmt.Print("🔍 检查测试服务器... ")
if !checkServer(client) {
fmt.Println("❌ 服务器未运行")
return
}
fmt.Println("✅")
fmt.Println()
total, passed, failed := 0, 0, 0
var errors []string
// 1. 基本 GET 路由测试
fmt.Println("📦 1. 基本 GET 路由测试")
total++
fmt.Print(" 🧪 GET 根路径... ")
if checkGetRoot(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "GET 根路径")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 GET 健康检查... ")
if checkGetHealth(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "GET 健康检查")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 GET 静态路由... ")
if checkGetStatic(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "GET 静态路由")
fmt.Println("❌")
}
fmt.Println()
// 2. 动态路由测试
fmt.Println("📦 2. 动态路由测试")
total++
fmt.Print(" 🧪 单参数路由... ")
if checkSingleParam(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "单参数路由")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 多参数路由... ")
if checkMultiParams(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "多参数路由")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 嵌套参数路由... ")
if checkNestedParams(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "嵌套参数路由")
fmt.Println("❌")
}
fmt.Println()
// 3. 查询参数测试
fmt.Println("📦 3. 查询参数测试")
total++
fmt.Print(" 🧪 单个查询参数... ")
if checkSingleQuery(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "单个查询参数")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 多个查询参数... ")
if checkMultiQuery(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "多个查询参数")
fmt.Println("❌")
}
fmt.Println()
// 4. 响应格式测试
fmt.Println("📦 4. 响应格式测试")
total++
fmt.Print(" 🧪 JSON 响应... ")
if checkJSONResponse(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "JSON 响应")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 HTML 响应... ")
if checkHTMLResponse(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "HTML 响应")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 自定义状态码 201... ")
if checkCustomStatus(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "自定义状态码 201")
fmt.Println("❌")
}
fmt.Println()
// 5. HTTP 方法测试
fmt.Println("📦 5. HTTP 方法测试")
total++
fmt.Print(" 🧪 POST 请求... ")
if checkPOST(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "POST 请求")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 PUT 请求... ")
if checkPUT(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "PUT 请求")
fmt.Println("❌")
}
total++
fmt.Print(" 🧪 DELETE 请求... ")
if checkDELETE(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "DELETE 请求")
fmt.Println("❌")
}
fmt.Println()
// 6. 错误处理测试
fmt.Println("📦 6. 错误处理测试")
total++
fmt.Print(" 🧪 404 未找到... ")
if checkNotFound(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "404 未找到")
fmt.Println("❌")
}
fmt.Println()
// 7. Keep-Alive 测试
fmt.Println("📦 7. Keep-Alive 连接测试")
total++
fmt.Print(" 🧪 连接复用... ")
if checkKeepAlive(client) {
passed++
fmt.Println("✅")
} else {
failed++
errors = append(errors, "连接复用")
fmt.Println("❌")
}
fmt.Println()
// 8. 性能测试
fmt.Println("📦 8. 性能测试")
total++
fmt.Print(" 🧪 吞吐量测试... ")
rps := checkThroughput(client)
if rps > 100 {
passed++
fmt.Printf("✅ (%.0f req/s)\n", rps)
} else {
failed++
errors = append(errors, "吞吐量")
fmt.Printf("❌ (%.0f req/s)\n", rps)
}
fmt.Println()
// 输出总结
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Printf("📊 测试结果: %d/%d 通过\n", passed, total)
if failed > 0 {
fmt.Println("❌ 失败的测试:")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
} else {
fmt.Println("🎉 所有测试通过!uSockets 后端验证成功!")
}
fmt.Println("═══════════════════════════════════════════════════════════════")
}
func checkServer(client *http.Client) bool {
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 200
}
func getBody(resp *http.Response) string {
body, _ := io.ReadAll(resp.Body)
return string(body)
}
func checkGetRoot(client *http.Client) bool {
resp, err := client.Get(baseURL + "/")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "Hello")
}
func checkGetHealth(client *http.Client) bool {
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && body == "OK"
}
func checkGetStatic(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/health")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && body == "OK"
}
func checkSingleParam(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/users/456")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "456")
}
func checkMultiParams(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/users/123/posts/789")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "123") && strings.Contains(body, "789")
}
func checkNestedParams(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/categories/tech/items/laptop")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "tech") && strings.Contains(body, "laptop")
}
func checkSingleQuery(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/search?q=test")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "test")
}
func checkMultiQuery(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/search?q=hello&limit=10&page=1")
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "hello")
}
func checkJSONResponse(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/json")
if err != nil {
return false
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
return resp.StatusCode == 200 && strings.Contains(ct, "application/json")
}
func checkHTMLResponse(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/html")
if err != nil {
return false
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
return resp.StatusCode == 200 && strings.Contains(ct, "text/html")
}
func checkCustomStatus(client *http.Client) bool {
resp, err := client.Get(baseURL + "/api/created")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 201
}
func checkPOST(client *http.Client) bool {
resp, err := client.Post(baseURL+"/api/users", "application/json", nil)
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 201 && strings.Contains(body, "created")
}
func checkPUT(client *http.Client) bool {
req, _ := http.NewRequest("PUT", baseURL+"/api/users/123", nil)
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "updated")
}
func checkDELETE(client *http.Client) bool {
req, _ := http.NewRequest("DELETE", baseURL+"/api/users/123", nil)
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
body := getBody(resp)
return resp.StatusCode == 200 && strings.Contains(body, "deleted")
}
func checkNotFound(client *http.Client) bool {
resp, err := client.Get(baseURL + "/nonexistent/path")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 404
}
func checkKeepAlive(client *http.Client) bool {
for i := 0; i < 10; i++ {
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
}
return true
}
func checkThroughput(client *http.Client) float64 {
start := time.Now()
concurrency := 1000
totalRequests := 1000000
requestsPerWorker := totalRequests / concurrency
// 创建共享的 Transport,启用连接池复用
transport := &http.Transport{
MaxIdleConns: concurrency,
MaxIdleConnsPerHost: concurrency,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
}
results := make(chan int, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
localSuccess := 0
// 每个 goroutine 使用独立 Client,但共享 Transport(连接池)
localClient := &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
}
for j := 0; j < requestsPerWorker; j++ {
resp, err := localClient.Get(baseURL + "/health")
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
localSuccess++
}
}
results <- localSuccess
}()
}
success := 0
for i := 0; i < concurrency; i++ {
success += <-results
}
elapsed := time.Since(start).Seconds()
fmt.Printf("\n [并发: %d, 总请求: %d, 成功: %d, 耗时: %.2fs]\n ", concurrency, totalRequests, success, elapsed)
return float64(success) / elapsed
}
+68
View File
@@ -336,3 +336,71 @@ pub fn (mut cache ContextLRUCache) clear() {
cache.size = 0
cache.last_cleanup = time.now().unix()
}
// ============================================================================
// 高性能路由缓存 - 专为路由匹配优化
// ============================================================================
// 特点:
// 1. 无 TTL 检查(路由不会过期)
// 2. 无 LRU 移动(简单 map 查找)
// 3. 固定大小,满了就清空重建
// 4. 零开销的 get 操作
pub struct FastRouteCache {
mut:
cache map[string]ContextRouteMatch
capacity int
enabled bool = true
}
pub fn FastRouteCache.new(capacity int) FastRouteCache {
return FastRouteCache{
cache: map[string]ContextRouteMatch{}
capacity: capacity
enabled: true
}
}
// 快速获取 - 零开销
@[inline]
pub fn (cache &FastRouteCache) get(key string) ?ContextRouteMatch {
if !cache.enabled {
return none
}
if result := cache.cache[key] {
return result
}
return none
}
// 快速设置
@[inline]
pub fn (mut cache FastRouteCache) put(key string, value ContextRouteMatch) {
if !cache.enabled {
return
}
// 如果满了,清空重建(比 LRU 淘汰更快)
if cache.cache.len >= cache.capacity {
cache.cache.clear()
}
cache.cache[key] = value
}
// 获取统计
pub fn (cache &FastRouteCache) get_stats() (int, int) {
return cache.cache.len, cache.capacity
}
// 清空缓存
pub fn (mut cache FastRouteCache) clear() {
cache.cache.clear()
}
// 启用/禁用
pub fn (mut cache FastRouteCache) set_enabled(enabled bool) {
cache.enabled = enabled
if !enabled {
cache.clear()
}
}
+648
View File
@@ -0,0 +1,648 @@
module hono
import time
import crypto.md5
// 分片上传管理器
// 负责管理分片上传的生命周期,包括初始化、上传分片、完成和取消上传
@[heap]
pub struct ChunkManager {
mut:
db DatabaseManager
provider &StorageProvider = unsafe { nil }
config ChunkManagerConfig
}
// 分片管理器配置
pub struct ChunkManagerConfig {
pub:
default_chunk_size int = 5 * 1024 * 1024 // 5MB 默认分片大小
max_chunk_size int = 100 * 1024 * 1024 // 100MB 最大分片大小
min_chunk_size int = 1024 * 1024 // 1MB 最小分片大小
max_parts int = 10000 // 最大分片数量
retry_count int = 3 // 重试次数
retry_delay_ms int = 1000 // 重试延迟(毫秒)
}
// 上传进度信息
pub struct UploadProgress {
pub:
upload_id string
file_uuid string
file_name string
file_size i64
chunk_size int
total_chunks int
uploaded_chunks int
uploaded_bytes i64
progress_pct f64
status string
pending_parts []int
created_at i64
updated_at i64
}
// 分片上传初始化参数
pub struct InitMultipartParams {
pub:
bucket string
object_key string
file_name string
file_size i64
content_type string
chunk_size int // 可选,为0时使用默认值
}
// 分片上传结果
pub struct ChunkUploadResult {
pub:
upload_id string
part_number int
etag string
size i64
success bool
error_msg string
}
// 创建分片管理器
pub fn new_chunk_manager(mut db DatabaseManager, provider &StorageProvider, config ChunkManagerConfig) ChunkManager {
return ChunkManager{
db: db
provider: unsafe { provider }
config: config
}
}
// 创建使用默认配置的分片管理器
pub fn new_chunk_manager_default(mut db DatabaseManager, provider &StorageProvider) ChunkManager {
return ChunkManager{
db: db
provider: unsafe { provider }
config: ChunkManagerConfig{}
}
}
// ============================================================================
// 核心分片上传操作
// ============================================================================
// 初始化分片上传
// 创建一个新的分片上传会话,返回 upload_id
pub fn (mut cm ChunkManager) init_multipart(params InitMultipartParams) !MultipartUpload {
// 验证参数
if params.bucket == '' {
return error('Bucket name is required')
}
if params.object_key == '' {
return error('Object key is required')
}
if params.file_size <= 0 {
return error('File size must be positive')
}
// 确定分片大小
chunk_size := if params.chunk_size > 0 {
cm.validate_chunk_size(params.chunk_size)
} else {
cm.config.default_chunk_size
}
// 计算总分片数
total_chunks := cm.calculate_total_chunks(params.file_size, chunk_size)
if total_chunks > cm.config.max_parts {
return error('File too large: would require ${total_chunks} parts, max is ${cm.config.max_parts}')
}
// 调用存储提供者初始化分片上传
provider_upload_id := cm.provider.init_multipart(params.bucket, params.object_key,
params.content_type)!
// 在数据库中创建上传记录
upload := cm.db.create_multipart_upload(MultipartUpload{
upload_id: provider_upload_id
file_name: params.file_name
file_size: params.file_size
chunk_size: chunk_size
total_chunks: total_chunks
bucket: params.bucket
object_key: params.object_key
content_type: params.content_type
})!
return upload
}
// 上传单个分片
pub fn (mut cm ChunkManager) upload_part(upload_id string, part_number int, data []u8) !ChunkUploadResult {
// 获取上传状态
upload := cm.db.get_multipart_upload(upload_id) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Upload not found: ${upload_id}'
}
}
// 验证上传状态
if upload.status != 'uploading' {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Upload is not in uploading state: ${upload.status}'
}
}
// 验证分片号
if part_number < 1 || part_number > upload.total_chunks {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Invalid part number: ${part_number}, expected 1-${upload.total_chunks}'
}
}
// 调用存储提供者上传分片
etag := cm.provider.upload_part(upload.bucket, upload.object_key, upload_id, part_number,
data) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to upload part: ${err}'
}
}
// 记录已上传的分片
cm.db.record_uploaded_part(upload_id, part_number, etag, i64(data.len)) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to record uploaded part: ${err}'
}
}
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
etag: etag
size: i64(data.len)
success: true
error_msg: ''
}
}
// 完成分片上传
// 合并所有分片并创建最终文件
pub fn (mut cm ChunkManager) complete_multipart(upload_id string) !StorageResult {
// 获取上传状态
upload := cm.db.get_multipart_upload(upload_id)!
// 验证上传状态
if upload.status != 'uploading' {
return error('Upload is not in uploading state: ${upload.status}')
}
// 获取所有已上传的分片
uploaded_parts := cm.db.get_uploaded_parts(upload_id)!
// 验证所有分片都已上传
if uploaded_parts.len != upload.total_chunks {
return error('Not all parts uploaded: ${uploaded_parts.len}/${upload.total_chunks}')
}
// 构建分片信息列表
mut parts := []PartInfo{}
for part in uploaded_parts {
parts << new_part_info(part.part_number, part.etag, part.size)
}
// 按分片号排序
parts.sort(a.part_number < b.part_number)
// 调用存储提供者完成上传
result := cm.provider.complete_multipart(upload.bucket, upload.object_key, upload_id,
parts)!
// 更新数据库状态
cm.db.update_multipart_status(upload_id, 'completed')!
// 在文件信息表中创建记录
file_hash := calculate_chunk_file_hash(upload_id, upload.file_size)
cm.db.insert_file(FileInfo{
file_uuid: upload.file_uuid
file_hash: file_hash
file_name: upload.file_name
file_size: upload.file_size
file_type: upload.content_type
storage_type: cm.provider.provider_name()
bucket: upload.bucket
object_key: upload.object_key
metadata: ''
}) or {
// 文件已存在,忽略错误
}
return result
}
// 取消分片上传
// 清理所有已上传的分片和临时数据
pub fn (mut cm ChunkManager) abort_multipart(upload_id string) ! {
// 获取上传状态
upload := cm.db.get_multipart_upload(upload_id) or {
return error('Upload not found: ${upload_id}')
}
// 调用存储提供者取消上传
cm.provider.abort_multipart(upload.bucket, upload.object_key, upload_id) or {
// 忽略存储提供者的错误,继续清理数据库
}
// 更新数据库状态
cm.db.update_multipart_status(upload_id, 'aborted')!
// 删除上传记录和分片记录
cm.db.delete_multipart_upload(upload_id)!
}
// ============================================================================
// 上传进度和状态查询
// ============================================================================
// 获取上传进度
pub fn (cm ChunkManager) get_upload_progress(upload_id string) !UploadProgress {
// 获取上传状态
upload := cm.db.get_multipart_upload(upload_id)!
// 获取已上传的分片
uploaded_parts := cm.db.get_uploaded_parts(upload_id)!
// 计算已上传字节数
mut uploaded_bytes := i64(0)
mut uploaded_part_numbers := map[int]bool{}
for part in uploaded_parts {
uploaded_bytes += part.size
uploaded_part_numbers[part.part_number] = true
}
// 计算待上传的分片
mut pending_parts := []int{}
for i in 1 .. upload.total_chunks + 1 {
if i !in uploaded_part_numbers {
pending_parts << i
}
}
// 计算进度百分比
progress_pct := if upload.total_chunks > 0 {
f64(uploaded_parts.len) / f64(upload.total_chunks) * 100.0
} else {
0.0
}
return UploadProgress{
upload_id: upload_id
file_uuid: upload.file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
uploaded_chunks: uploaded_parts.len
uploaded_bytes: uploaded_bytes
progress_pct: progress_pct
status: upload.status
pending_parts: pending_parts
created_at: upload.created_at
updated_at: upload.updated_at
}
}
// 检查分片是否已上传
pub fn (cm ChunkManager) is_part_uploaded(upload_id string, part_number int) bool {
return cm.db.is_part_uploaded(upload_id, part_number)
}
// 获取已上传的分片列表
pub fn (cm ChunkManager) get_uploaded_parts(upload_id string) ![]UploadedPart {
return cm.db.get_uploaded_parts(upload_id)
}
// 列出所有进行中的上传
pub fn (cm ChunkManager) list_pending_uploads() ![]MultipartUpload {
return cm.db.list_pending_multipart_uploads()
}
// ============================================================================
// 分片重试和恢复
// ============================================================================
// 重试上传单个分片
// 带有指数退避重试逻辑
pub fn (mut cm ChunkManager) retry_upload_part(upload_id string, part_number int, data []u8) !ChunkUploadResult {
mut last_error := ''
mut delay := cm.config.retry_delay_ms
for attempt in 0 .. cm.config.retry_count + 1 {
result := cm.upload_part(upload_id, part_number, data)!
if result.success {
return result
}
last_error = result.error_msg
// 检查是否是可重试的错误
if !cm.is_retryable_error(result.error_msg) {
return result
}
// 如果不是最后一次尝试,等待后重试
if attempt < cm.config.retry_count {
time.sleep(delay * time.millisecond)
delay = delay * 2 // 指数退避
if delay > 30000 {
delay = 30000 // 最大延迟 30 秒
}
}
}
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'All retry attempts failed: ${last_error}'
}
}
// 恢复上传(断点续传)
// 返回需要上传的分片列表
pub fn (cm ChunkManager) get_pending_parts(upload_id string) ![]int {
progress := cm.get_upload_progress(upload_id)!
return progress.pending_parts
}
// 批量上传待处理的分片
// data_provider 是一个函数,根据分片号返回分片数据
pub fn (mut cm ChunkManager) upload_pending_parts(upload_id string, data_provider fn (int) ![]u8) ![]ChunkUploadResult {
pending_parts := cm.get_pending_parts(upload_id)!
mut results := []ChunkUploadResult{}
for part_number in pending_parts {
data := data_provider(part_number) or {
results << ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to get data for part ${part_number}: ${err}'
}
continue
}
result := cm.retry_upload_part(upload_id, part_number, data) or {
results << ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to upload part ${part_number}: ${err}'
}
continue
}
results << result
}
return results
}
// ============================================================================
// 辅助方法
// ============================================================================
// 验证分片大小
fn (cm ChunkManager) validate_chunk_size(size int) int {
if size < cm.config.min_chunk_size {
return cm.config.min_chunk_size
}
if size > cm.config.max_chunk_size {
return cm.config.max_chunk_size
}
return size
}
// 计算总分片数
fn (cm ChunkManager) calculate_total_chunks(file_size i64, chunk_size int) int {
chunks := file_size / i64(chunk_size)
if file_size % i64(chunk_size) > 0 {
return int(chunks) + 1
}
return int(chunks)
}
// 判断错误是否可重试
fn (cm ChunkManager) is_retryable_error(error_msg string) bool {
retryable_keywords := ['timeout', 'connection', 'network', 'unavailable', 'rate limit',
'temporary']
lower_msg := error_msg.to_lower()
for keyword in retryable_keywords {
if lower_msg.contains(keyword) {
return true
}
}
return false
}
// 计算文件哈希(用于文件记录)
fn calculate_chunk_file_hash(upload_id string, file_size i64) string {
// 使用 upload_id 和 file_size 生成一个简单的哈希
// 实际应用中应该使用文件内容的 MD5/SHA256
data := '${upload_id}-${file_size}'.bytes()
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return result
}
// 获取分片的数据范围
pub fn (cm ChunkManager) get_part_range(upload_id string, part_number int) !(i64, i64) {
upload := cm.db.get_multipart_upload(upload_id)!
if part_number < 1 || part_number > upload.total_chunks {
return error('Invalid part number: ${part_number}')
}
start := i64(part_number - 1) * i64(upload.chunk_size)
mut end := start + i64(upload.chunk_size) - 1
// 最后一个分片可能小于 chunk_size
if end >= upload.file_size {
end = upload.file_size - 1
}
return start, end
}
// 获取分片的预期大小
pub fn (cm ChunkManager) get_expected_part_size(upload_id string, part_number int) !i64 {
start, end := cm.get_part_range(upload_id, part_number)!
return end - start + 1
}
// ============================================================================
// 断点续传支持
// ============================================================================
// 恢复上传会话信息
// 用于客户端重新连接后恢复上传状态
pub struct ResumeInfo {
pub:
upload_id string
file_uuid string
file_name string
file_size i64
chunk_size int
total_chunks int
uploaded_chunks int
pending_parts []int
can_resume bool
error_msg string
}
// 获取恢复上传所需的信息
pub fn (cm ChunkManager) get_resume_info(upload_id string) ResumeInfo {
upload := cm.db.get_multipart_upload(upload_id) or {
return ResumeInfo{
upload_id: upload_id
can_resume: false
error_msg: 'Upload not found: ${upload_id}'
}
}
// 只有 uploading 状态的上传可以恢复
if upload.status != 'uploading' {
return ResumeInfo{
upload_id: upload_id
file_uuid: upload.file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
can_resume: false
error_msg: 'Upload is in ${upload.status} state, cannot resume'
}
}
// 获取进度信息
progress := cm.get_upload_progress(upload_id) or {
return ResumeInfo{
upload_id: upload_id
file_uuid: upload.file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
can_resume: false
error_msg: 'Failed to get upload progress: ${err}'
}
}
return ResumeInfo{
upload_id: upload_id
file_uuid: upload.file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
uploaded_chunks: progress.uploaded_chunks
pending_parts: progress.pending_parts
can_resume: true
error_msg: ''
}
}
// 根据文件名查找可恢复的上传
pub fn (cm ChunkManager) find_resumable_upload(file_name string, file_size i64) ?MultipartUpload {
pending_uploads := cm.db.list_pending_multipart_uploads() or { return none }
for upload in pending_uploads {
if upload.file_name == file_name && upload.file_size == file_size {
return upload
}
}
return none
}
// 检查上传是否可以完成(所有分片都已上传)
pub fn (cm ChunkManager) can_complete(upload_id string) bool {
progress := cm.get_upload_progress(upload_id) or { return false }
return progress.pending_parts.len == 0 && progress.status == 'uploading'
}
// 获取上传的详细状态报告
pub struct UploadStatusReport {
pub:
upload_id string
status string
file_name string
file_size i64
total_chunks int
uploaded_chunks int
pending_chunks int
uploaded_bytes i64
remaining_bytes i64
progress_pct f64
estimated_time_ms i64 // 基于平均上传速度估算的剩余时间
can_complete bool
can_resume bool
}
// 获取详细的上传状态报告
pub fn (cm ChunkManager) get_status_report(upload_id string) !UploadStatusReport {
progress := cm.get_upload_progress(upload_id)!
remaining_bytes := progress.file_size - progress.uploaded_bytes
can_complete := progress.pending_parts.len == 0 && progress.status == 'uploading'
can_resume := progress.status == 'uploading' && progress.pending_parts.len > 0
return UploadStatusReport{
upload_id: upload_id
status: progress.status
file_name: progress.file_name
file_size: progress.file_size
total_chunks: progress.total_chunks
uploaded_chunks: progress.uploaded_chunks
pending_chunks: progress.pending_parts.len
uploaded_bytes: progress.uploaded_bytes
remaining_bytes: remaining_bytes
progress_pct: progress.progress_pct
estimated_time_ms: 0 // 需要历史数据来估算
can_complete: can_complete
can_resume: can_resume
}
}
// 清理过期的上传(超过指定时间未更新的上传)
pub fn (mut cm ChunkManager) cleanup_stale_uploads(max_age_seconds i64) !int {
pending_uploads := cm.db.list_pending_multipart_uploads()!
now := time.now().unix()
mut cleaned := 0
for upload in pending_uploads {
if now - upload.updated_at > max_age_seconds {
cm.abort_multipart(upload.upload_id) or { continue }
cleaned++
}
}
return cleaned
}
+249
View File
@@ -0,0 +1,249 @@
module hono
import net.http
import compress.gzip
import compress.zlib
// CompressEncoding 枚举 - 支持的压缩编码类型
pub enum CompressEncoding {
gzip
deflate
}
// CompressOptions 结构体 - 压缩配置选项
pub struct CompressOptions {
pub:
encoding ?CompressEncoding // 指定编码,none 表示自动选择
threshold int = 1024 // 最小压缩大小(字节),默认 1KB
level int = 128 // 压缩级别 (0-4095 for gzip)
}
// compress - 压缩中间件工厂函数
// 返回一个 ContextMiddleware,用于压缩响应体
pub fn compress(options ...CompressOptions) ContextMiddleware {
opts := if options.len > 0 { options[0] } else { CompressOptions{} }
return fn [opts] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 先执行后续处理器获取响应
mut response := next(mut c)
// 检查 Cache-Control: no-transform
if has_no_transform(response) {
return response
}
// 检查响应体大小是否达到阈值
if response.body.len < opts.threshold {
return response
}
// 检查 Content-Type 是否可压缩
content_type := get_response_content_type(response)
if !is_compressible_content_type(content_type) {
return response
}
// 检查响应是否已经被压缩
if is_already_compressed(response) {
return response
}
// 获取客户端支持的编码
accept_encoding := c.req.header.get_custom('Accept-Encoding') or { '' }
if accept_encoding.len == 0 {
return response
}
// 选择压缩编码
encoding := select_encoding(accept_encoding, opts.encoding)
if encoding == none {
return response
}
// 执行压缩
selected_encoding := encoding or { return response }
compressed_body := compress_body(response.body, selected_encoding, opts.level) or {
// 压缩失败,返回原始响应
return response
}
// 如果压缩后更大,返回原始响应
if compressed_body.len >= response.body.len {
return response
}
// 构建新的响应头
mut new_header := http.new_header()
// 复制原有头部
for key in response.header.keys() {
values := response.header.custom_values(key)
for value in values {
// 跳过 Content-Length,稍后重新设置
if key.to_lower() != 'content-length' {
new_header.add_custom(key, value) or { continue }
}
}
}
// 设置 Content-Encoding
encoding_str := if selected_encoding == .gzip { 'gzip' } else { 'deflate' }
new_header.add_custom('Content-Encoding', encoding_str) or {}
// 设置新的 Content-Length
new_header.add_custom('Content-Length', compressed_body.len.str()) or {}
// 添加 Vary 头,表示响应根据 Accept-Encoding 变化
new_header.add_custom('Vary', 'Accept-Encoding') or {}
return http.Response{
status_code: response.status_code
header: new_header
body: compressed_body.bytestr()
}
}
}
// has_no_transform - 检查响应是否包含 Cache-Control: no-transform
fn has_no_transform(response http.Response) bool {
cache_control := response.header.get_custom('Cache-Control') or { '' }
return cache_control.contains('no-transform')
}
// get_response_content_type - 获取响应的 Content-Type
fn get_response_content_type(response http.Response) string {
return response.header.get_custom('Content-Type') or { '' }
}
// is_compressible_content_type - 检查 Content-Type 是否可压缩
fn is_compressible_content_type(content_type string) bool {
if content_type.len == 0 {
return true // 默认可压缩
}
ct_lower := content_type.to_lower()
// 可压缩的 MIME 类型
compressible_types := [
'text/',
'application/json',
'application/javascript',
'application/xml',
'application/xhtml+xml',
'application/rss+xml',
'application/atom+xml',
'application/x-javascript',
'application/x-font-ttf',
'font/opentype',
'font/ttf',
'font/eot',
'image/svg+xml',
'image/x-icon',
'image/vnd.microsoft.icon',
]
for ct in compressible_types {
if ct_lower.starts_with(ct) || ct_lower.contains(ct) {
return true
}
}
// 不可压缩的类型(已压缩的格式)
non_compressible_types := [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'video/',
'audio/',
'application/zip',
'application/gzip',
'application/x-gzip',
'application/x-compress',
'application/x-compressed',
'application/x-bzip',
'application/x-bzip2',
'application/x-rar-compressed',
'application/x-7z-compressed',
]
for ct in non_compressible_types {
if ct_lower.starts_with(ct) || ct_lower.contains(ct) {
return false
}
}
return true
}
// is_already_compressed - 检查响应是否已经被压缩
fn is_already_compressed(response http.Response) bool {
content_encoding := response.header.get_custom('Content-Encoding') or { '' }
return content_encoding.len > 0
}
// select_encoding - 选择最佳压缩编码
fn select_encoding(accept_encoding string, preferred ?CompressEncoding) ?CompressEncoding {
// 如果指定了首选编码,检查客户端是否支持
if pref := preferred {
match pref {
.gzip {
if accepts_gzip(accept_encoding) {
return .gzip
}
}
.deflate {
if accepts_deflate(accept_encoding) {
return .deflate
}
}
}
return none
}
// 自动选择:优先 gzip,其次 deflate
if accepts_gzip(accept_encoding) {
return .gzip
}
if accepts_deflate(accept_encoding) {
return .deflate
}
return none
}
// accepts_gzip - 检查是否接受 gzip 编码
fn accepts_gzip(accept_encoding string) bool {
return accept_encoding.contains('gzip') || accept_encoding.contains('*')
}
// accepts_deflate - 检查是否接受 deflate 编码
fn accepts_deflate(accept_encoding string) bool {
return accept_encoding.contains('deflate') || accept_encoding.contains('*')
}
// compress_body - 压缩响应体
fn compress_body(body string, encoding CompressEncoding, level int) ![]u8 {
data := body.bytes()
match encoding {
.gzip {
return gzip.compress(data, gzip.CompressParams{
compression_level: level
})
}
.deflate {
return zlib.compress(data)
}
}
}
// decompress_gzip - 解压 gzip 数据(用于测试)
pub fn decompress_gzip(data []u8) ![]u8 {
return gzip.decompress(data)
}
// decompress_deflate - 解压 deflate 数据(用于测试)
pub fn decompress_deflate(data []u8) ![]u8 {
return zlib.decompress(data)
}
+326
View File
@@ -0,0 +1,326 @@
module hono
import time
import crypto.sha256
import encoding.base64
// SameSite 枚举 - Cookie 的 SameSite 属性
pub enum SameSite {
strict
lax
none_
}
// CookieOptions 结构体 - Cookie 配置选项
pub struct CookieOptions {
pub:
path string = '/'
domain string
max_age int // 秒
expires ?time.Time
http_only bool
secure bool
same_site SameSite = .lax
}
// get_cookie - 从请求中获取指定名称的 Cookie 值
// 返回 Cookie 值,如果不存在则返回 none
pub fn get_cookie(c Context, name string) ?string {
cookie_header := c.req.header.get_custom('Cookie') or { return none }
if cookie_header.len == 0 {
return none
}
// 解析 Cookie 头
cookies := parse_cookie_header(cookie_header)
if name in cookies {
return cookies[name]
}
return none
}
// get_all_cookies - 获取请求中的所有 Cookie
// 返回一个 map,包含所有 Cookie 的名称和值
pub fn get_all_cookies(c Context) map[string]string {
cookie_header := c.req.header.get_custom('Cookie') or { return map[string]string{} }
if cookie_header.len == 0 {
return map[string]string{}
}
return parse_cookie_header(cookie_header)
}
// set_cookie - 设置 Cookie
// 将 Cookie 添加到响应的 Set-Cookie 头中
pub fn set_cookie(mut c Context, name string, value string, options ...CookieOptions) {
opts := if options.len > 0 { options[0] } else { CookieOptions{} }
cookie_str := build_cookie_string(name, value, opts)
// 添加到响应头
// 如果已经有 Set-Cookie 头,需要追加
if existing := c.headers['Set-Cookie'] {
c.headers['Set-Cookie'] = '${existing}, ${cookie_str}'
} else {
c.headers['Set-Cookie'] = cookie_str
}
}
// delete_cookie - 删除 Cookie
// 通过设置过期时间为过去的时间来删除 Cookie
pub fn delete_cookie(mut c Context, name string, options ...CookieOptions) {
mut opts := if options.len > 0 { options[0] } else { CookieOptions{} }
// 设置 max_age 为 0 或负数,并设置过期时间为过去
expired_time := time.unix(0)
// 创建新的选项,保留 path 和 domain,但设置过期
delete_opts := CookieOptions{
path: opts.path
domain: opts.domain
max_age: 0
expires: expired_time
http_only: opts.http_only
secure: opts.secure
same_site: opts.same_site
}
cookie_str := build_cookie_string(name, '', delete_opts)
if existing := c.headers['Set-Cookie'] {
c.headers['Set-Cookie'] = '${existing}, ${cookie_str}'
} else {
c.headers['Set-Cookie'] = cookie_str
}
}
// parse_cookie_header - 解析 Cookie 请求头
// 将 "name1=value1; name2=value2" 格式解析为 map
fn parse_cookie_header(header string) map[string]string {
mut cookies := map[string]string{}
// 按分号分割
pairs := header.split(';')
for pair in pairs {
trimmed := pair.trim_space()
if trimmed.len == 0 {
continue
}
// 找到第一个等号的位置
eq_pos := trimmed.index('=') or { continue }
if eq_pos == 0 {
continue
}
name := trimmed[..eq_pos].trim_space()
value := if eq_pos + 1 < trimmed.len {
trimmed[eq_pos + 1..].trim_space()
} else {
''
}
// 移除值两端的引号(如果有)
cleaned_value := if value.len >= 2 && value[0] == `"` && value[value.len - 1] == `"` {
value[1..value.len - 1]
} else {
value
}
cookies[name] = cleaned_value
}
return cookies
}
// build_cookie_string - 构建 Set-Cookie 头的值
fn build_cookie_string(name string, value string, opts CookieOptions) string {
mut parts := []string{}
// 基本的 name=value
parts << '${name}=${value}'
// Path
if opts.path.len > 0 {
parts << 'Path=${opts.path}'
}
// Domain
if opts.domain.len > 0 {
parts << 'Domain=${opts.domain}'
}
// Max-Age
if opts.max_age != 0 {
parts << 'Max-Age=${opts.max_age}'
}
// Expires
if expires := opts.expires {
// 格式化为 HTTP 日期格式: Wed, 09 Jun 2021 10:18:14 GMT
expires_str := format_cookie_date(expires)
parts << 'Expires=${expires_str}'
}
// HttpOnly
if opts.http_only {
parts << 'HttpOnly'
}
// Secure
if opts.secure {
parts << 'Secure'
}
// SameSite
match opts.same_site {
.strict { parts << 'SameSite=Strict' }
.lax { parts << 'SameSite=Lax' }
.none_ { parts << 'SameSite=None' }
}
return parts.join('; ')
}
// format_cookie_date - 格式化时间为 Cookie 日期格式
// 格式: Wed, 09 Jun 2021 10:18:14 GMT
fn format_cookie_date(t time.Time) string {
days := ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
months := ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
day_name := days[t.day_of_week()]
month_name := months[t.month - 1]
return '${day_name}, ${t.day:02} ${month_name} ${t.year} ${t.hour:02}:${t.minute:02}:${t.second:02} GMT'
}
// set_signed_cookie - 设置签名 Cookie
// 使用 HMAC-SHA256 对 Cookie 值进行签名
pub fn set_signed_cookie(mut c Context, name string, value string, secret string, options ...CookieOptions) ! {
if secret.len == 0 {
return error('Secret is required for signed cookies')
}
// 生成签名
signature := generate_hmac_signature(value, secret)
// 将值和签名组合: value.signature
signed_value := '${value}.${signature}'
opts := if options.len > 0 { options[0] } else { CookieOptions{} }
set_cookie(mut c, name, signed_value, opts)
}
// get_signed_cookie - 获取并验证签名 Cookie
// 验证签名,如果有效则返回原始值,否则返回错误
pub fn get_signed_cookie(c Context, name string, secret string) !string {
if secret.len == 0 {
return error('Secret is required for signed cookies')
}
// 获取 Cookie 值
signed_value := get_cookie(c, name) or {
return error('Cookie not found: ${name}')
}
// 分离值和签名
dot_pos := signed_value.last_index('.') or {
return error('Invalid signed cookie format')
}
if dot_pos == 0 || dot_pos >= signed_value.len - 1 {
return error('Invalid signed cookie format')
}
value := signed_value[..dot_pos]
signature := signed_value[dot_pos + 1..]
// 验证签名
expected_signature := generate_hmac_signature(value, secret)
if !constant_time_compare(signature, expected_signature) {
return error('Invalid signature')
}
return value
}
// generate_hmac_signature - 使用 HMAC-SHA256 生成签名
fn generate_hmac_signature(value string, secret string) string {
// 手动实现 HMAC-SHA256
// HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))
// 其中 K' 是处理后的密钥,ipad = 0x36, opad = 0x5c
block_size := 64 // SHA256 块大小
// 处理密钥
mut key := secret.bytes()
if key.len > block_size {
// 如果密钥太长,先哈希
hash_result := sha256.sum(key)
key = []u8{len: 32}
for i := 0; i < 32; i++ {
key[i] = hash_result[i]
}
}
// 填充到块大小
for key.len < block_size {
key << u8(0)
}
// 计算 K' ⊕ ipad
mut i_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
i_key_pad[i] = key[i] ^ u8(0x36)
}
// 计算 K' ⊕ opad
mut o_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
o_key_pad[i] = key[i] ^ u8(0x5c)
}
// 计算内部哈希: H((K' ⊕ ipad) || m)
mut inner_data := i_key_pad.clone()
inner_data << value.bytes()
inner_hash_result := sha256.sum(inner_data)
mut inner_hash := []u8{len: 32}
for i := 0; i < 32; i++ {
inner_hash[i] = inner_hash_result[i]
}
// 计算外部哈希: H((K' ⊕ opad) || inner_hash)
mut outer_data := o_key_pad.clone()
outer_data << inner_hash
outer_hash_result := sha256.sum(outer_data)
mut outer_hash := []u8{len: 32}
for i := 0; i < 32; i++ {
outer_hash[i] = outer_hash_result[i]
}
// Base64URL 编码(不带填充)
return base64.url_encode(outer_hash).replace('=', '')
}
// constant_time_compare - 常量时间比较,防止时序攻击
fn constant_time_compare(a string, b string) bool {
if a.len != b.len {
return false
}
mut result := u8(0)
for i := 0; i < a.len; i++ {
result |= a[i] ^ b[i]
}
return result == 0
}
+135
View File
@@ -0,0 +1,135 @@
module hono
import net.http
// CorsOrigin 类型 - 支持多种来源配置方式
// 可以是字符串(单域名或 "*")、字符串数组(多域名)或回调函数
pub type CorsOrigin = string | []string | fn (string, Context) string
// CorsOptions 结构体 - CORS 配置选项
pub struct CorsOptions {
pub:
origin CorsOrigin = '*' // 允许的来源,默认允许所有
allow_methods []string = ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']
allow_headers []string = [] // 允许的请求头
expose_headers []string = [] // 暴露的响应头
max_age int // 预检请求缓存时间(秒),默认 0
credentials bool // 是否允许凭证,默认 false
}
// cors - CORS 中间件工厂函数
// 返回一个 ContextMiddleware,用于处理跨域请求
pub fn cors(options ...CorsOptions) ContextMiddleware {
opts := if options.len > 0 { options[0] } else { CorsOptions{} }
return fn [opts] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 获取请求的 Origin 头
origin := c.req.header.get_custom('Origin') or { '' }
// 如果没有 Origin 头,直接继续处理
if origin.len == 0 {
return next(mut c)
}
// 计算允许的 Origin
allowed_origin := get_allowed_origin(origin, opts.origin, c)
// 如果 Origin 不被允许,直接继续处理(不设置 CORS 头)
if allowed_origin.len == 0 {
return next(mut c)
}
// 设置 Access-Control-Allow-Origin
c.headers['Access-Control-Allow-Origin'] = allowed_origin
// 设置 Access-Control-Allow-Credentials
if opts.credentials {
c.headers['Access-Control-Allow-Credentials'] = 'true'
}
// 设置 Access-Control-Expose-Headers
if opts.expose_headers.len > 0 {
c.headers['Access-Control-Expose-Headers'] = opts.expose_headers.join(', ')
}
// 检查是否为预检请求 (OPTIONS)
if c.req.method == http.Method.options {
return handle_preflight(mut c, opts)
}
// 非预检请求,继续处理
return next(mut c)
}
}
// get_allowed_origin - 根据配置计算允许的 Origin
fn get_allowed_origin(request_origin string, origin_config CorsOrigin, c Context) string {
match origin_config {
string {
// 单个字符串配置
if origin_config == '*' {
// 通配符,允许所有来源
return '*'
}
// 特定域名,检查是否匹配
if origin_config == request_origin {
return request_origin
}
return ''
}
[]string {
// 多域名配置,检查请求来源是否在列表中
for allowed in origin_config {
if allowed == '*' {
return '*'
}
if allowed == request_origin {
return request_origin
}
}
return ''
}
fn (string, Context) string {
// 回调函数配置
return origin_config(request_origin, c)
}
}
}
// handle_preflight - 处理 OPTIONS 预检请求
fn handle_preflight(mut c Context, opts CorsOptions) http.Response {
// 设置 Access-Control-Allow-Methods
if opts.allow_methods.len > 0 {
c.headers['Access-Control-Allow-Methods'] = opts.allow_methods.join(', ')
}
// 设置 Access-Control-Allow-Headers
if opts.allow_headers.len > 0 {
c.headers['Access-Control-Allow-Headers'] = opts.allow_headers.join(', ')
} else {
// 如果没有配置,尝试使用请求中的 Access-Control-Request-Headers
if request_headers := c.req.header.get_custom('Access-Control-Request-Headers') {
c.headers['Access-Control-Allow-Headers'] = request_headers
}
}
// 设置 Access-Control-Max-Age
if opts.max_age > 0 {
c.headers['Access-Control-Max-Age'] = opts.max_age.str()
}
// 返回 204 No Content
c.status(204)
mut headers := http.new_header()
headers.add_custom('Connection', 'keep-alive') or {}
for key, value in c.headers {
headers.add_custom(key, value) or { continue }
}
return http.Response{
status_code: c.status_code
header: headers
body: ''
}
}
+539 -123
View File
@@ -4,18 +4,73 @@ import db.sqlite
import time
import crypto.rand
// 文件信息结构
// 文件信息结构(扩展版本,支持多存储提供者)
pub struct FileInfo {
pub:
id int
file_uuid string
file_hash string
file_name string
file_size int
file_size i64
file_type string
created_at int
updated_at int
storage_type string // 存储类型 (local/s3/oss/cos)
bucket string // 存储桶名称
object_key string // 对象键(存储路径)
created_at i64
updated_at i64
metadata string // JSON 格式的额外元数据
}
// 分片上传状态
pub struct MultipartUpload {
pub:
id int
upload_id string
file_uuid string
file_name string
file_size i64
chunk_size int
total_chunks int
bucket string
object_key string
content_type string
status string // 'uploading', 'completed', 'aborted'
created_at i64
updated_at i64
}
// 已上传分片记录
pub struct UploadedPart {
pub:
id int
upload_id string
part_number int
etag string
size i64
uploaded_at i64
}
// 文件列表查询选项
pub struct FileListOptions {
pub:
bucket string
prefix string
storage_type string
limit int = 100
offset int
order_by string = 'created_at'
order_desc bool = true
}
// 文件列表查询结果
pub struct FileListResult {
pub:
files []FileInfo
total_count int
has_more bool
}
// 数据库管理器
pub struct DatabaseManager {
mut:
@@ -25,10 +80,10 @@ mut:
// 创建数据库管理器
pub fn new_database_manager(db_path string) !DatabaseManager {
mut db := sqlite.connect(db_path) or {
return error('Failed to connect to database: $err')
return error('Failed to connect to database: ${err}')
}
// 创建文件信息表
// 创建文件信息表(扩展版本,支持多存储提供者)
db.exec('CREATE TABLE IF NOT EXISTS file_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_uuid TEXT UNIQUE NOT NULL,
@@ -36,18 +91,60 @@ pub fn new_database_manager(db_path string) !DatabaseManager {
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_type TEXT NOT NULL,
storage_type TEXT NOT NULL DEFAULT "local",
bucket TEXT NOT NULL DEFAULT "default",
object_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);') or {
return error('Failed to create table: $err')
}
updated_at INTEGER NOT NULL,
metadata TEXT
);') or { return error('Failed to create file_info table: ${err}') }
// 创建索引
db.exec('CREATE INDEX IF NOT EXISTS idx_file_hash ON file_info(file_hash);') or {
return error('Failed to create index: $err')
return error('Failed to create idx_file_hash index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_file_uuid ON file_info(file_uuid);') or {
return error('Failed to create index: $err')
return error('Failed to create idx_file_uuid index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_bucket_key ON file_info(bucket, object_key);') or {
return error('Failed to create idx_bucket_key index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_storage_type ON file_info(storage_type);') or {
return error('Failed to create idx_storage_type index: ${err}')
}
// 创建分片上传状态表
db.exec("CREATE TABLE IF NOT EXISTS multipart_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id TEXT UNIQUE NOT NULL,
file_uuid TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
chunk_size INTEGER NOT NULL,
total_chunks INTEGER NOT NULL,
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
content_type TEXT,
status TEXT NOT NULL DEFAULT 'uploading',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);") or { return error('Failed to create multipart_uploads table: ${err}') }
// 创建已上传分片记录表
db.exec('CREATE TABLE IF NOT EXISTS uploaded_parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id TEXT NOT NULL,
part_number INTEGER NOT NULL,
etag TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at INTEGER NOT NULL,
UNIQUE(upload_id, part_number)
);') or { return error('Failed to create uploaded_parts table: ${err}') }
db.exec('CREATE INDEX IF NOT EXISTS idx_upload_id ON uploaded_parts(upload_id);') or {
return error('Failed to create idx_upload_id index: ${err}')
}
return DatabaseManager{
db: db
}
@@ -57,156 +154,475 @@ pub fn new_database_manager(db_path string) !DatabaseManager {
pub fn generate_file_uuid() string {
// 生成16字节的随机数据
random_bytes := rand.bytes(16) or { return '' }
// 转换为UUID格式 (8-4-4-4-12)
mut uuid := ''
for i, byte in random_bytes {
for i, b in random_bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
uuid += '-'
}
uuid += '${byte:02x}'
uuid += '${b:02x}'
}
return uuid
}
// 插入或更新文件信息
pub fn (mut db DatabaseManager) insert_or_update_file(file_hash string, file_name string, file_size int, file_type string) !FileInfo {
now := int(time.now().unix())
// 检查是否已存在相同的file_hash
existing_file := db.get_file_by_hash(file_hash) or { FileInfo{} }
if existing_file.file_uuid != '' {
// 如果存在相同的file_hash但文件名不同,创建新记录
if existing_file.file_name != file_name {
// 生成新的UUID
file_uuid := generate_file_uuid()
// 插入新记录
db.db.exec('INSERT INTO file_info (file_uuid, file_hash, file_name, file_size, file_type, created_at, updated_at) VALUES ("$file_uuid", "$file_hash", "$file_name", $file_size, "$file_type", $now, $now)') or {
return error('Failed to insert file info: $err')
}
return FileInfo{
file_uuid: file_uuid
file_hash: file_hash
file_name: file_name
file_size: file_size
file_type: file_type
created_at: now
updated_at: now
}
} else {
// 文件名相同,更新现有记录
db.db.exec('UPDATE file_info SET file_size = $file_size, file_type = "$file_type", updated_at = $now WHERE file_hash = "$file_hash"') or {
return error('Failed to update file info: $err')
}
return FileInfo{
file_uuid: existing_file.file_uuid
file_hash: file_hash
file_name: file_name
file_size: file_size
file_type: file_type
created_at: existing_file.created_at
updated_at: now
}
}
} else {
// 不存在相同的file_hash,创建新记录
file_uuid := generate_file_uuid()
db.db.exec('INSERT INTO file_info (file_uuid, file_hash, file_name, file_size, file_type, created_at, updated_at) VALUES ("$file_uuid", "$file_hash", "$file_name", $file_size, "$file_type", $now, $now)') or {
return error('Failed to insert file info: $err')
}
return FileInfo{
file_uuid: file_uuid
file_hash: file_hash
file_name: file_name
file_size: file_size
file_type: file_type
created_at: now
updated_at: now
}
// 生成上传ID (用于数据库记录)
pub fn generate_db_upload_id() string {
random_bytes := rand.bytes(16) or { return '' }
mut id := ''
for b in random_bytes {
id += '${b:02x}'
}
return id
}
// ============ 文件元数据 CRUD 操作 ============
// 插入文件信息
pub fn (mut dm DatabaseManager) insert_file(file FileInfo) !FileInfo {
now := time.now().unix()
file_uuid := if file.file_uuid != '' { file.file_uuid } else { generate_file_uuid() }
dm.db.exec("INSERT INTO file_info (file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata) VALUES ('${file_uuid}', '${file.file_hash}', '${escape_sql(file.file_name)}', ${file.file_size}, '${file.file_type}', '${file.storage_type}', '${file.bucket}', '${escape_sql(file.object_key)}', ${now}, ${now}, '${escape_sql(file.metadata)}')") or {
return error('Failed to insert file info: ${err}')
}
return FileInfo{
id: 0
file_uuid: file_uuid
file_hash: file.file_hash
file_name: file.file_name
file_size: file.file_size
file_type: file.file_type
storage_type: file.storage_type
bucket: file.bucket
object_key: file.object_key
created_at: now
updated_at: now
metadata: file.metadata
}
}
// 根据文件hash获取文件信息
pub fn (db DatabaseManager) get_file_by_hash(file_hash string) !FileInfo {
rows := db.db.exec('SELECT file_uuid, file_hash, file_name, file_size, file_type, created_at, updated_at FROM file_info WHERE file_hash = "$file_hash"') or {
return error('Failed to query file info: $err')
// 根据UUID获取文件信息
pub fn (dm DatabaseManager) get_file_by_uuid(file_uuid string) !FileInfo {
rows := dm.db.exec("SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info WHERE file_uuid = '${file_uuid}'") or {
return error('Failed to query file info: ${err}')
}
if rows.len > 0 {
row := rows[0]
return FileInfo{
file_uuid: row.vals[0]
file_hash: row.vals[1]
file_name: row.vals[2]
file_size: row.vals[3].int()
file_type: row.vals[4]
created_at: row.vals[5].int()
updated_at: row.vals[6].int()
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return error('File not found')
}
// 根据文件UUID获取文件信息
pub fn (db DatabaseManager) get_file_by_uuid(file_uuid string) !FileInfo {
rows := db.db.exec('SELECT file_uuid, file_hash, file_name, file_size, file_type, created_at, updated_at FROM file_info WHERE file_uuid = "$file_uuid"') or {
return error('Failed to query file info: $err')
// 根据hash获取文件信息
pub fn (dm DatabaseManager) get_file_by_hash(file_hash string) !FileInfo {
rows := dm.db.exec("SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info WHERE file_hash = '${file_hash}'") or {
return error('Failed to query file info: ${err}')
}
if rows.len > 0 {
row := rows[0]
return FileInfo{
file_uuid: row.vals[0]
file_hash: row.vals[1]
file_name: row.vals[2]
file_size: row.vals[3].int()
file_type: row.vals[4]
created_at: row.vals[5].int()
updated_at: row.vals[6].int()
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return error('File not found')
}
// 获取所有文件信息
pub fn (db DatabaseManager) get_all_files() ![]FileInfo {
rows := db.db.exec('SELECT file_uuid, file_hash, file_name, file_size, file_type, created_at, updated_at FROM file_info ORDER BY created_at DESC') or {
return error('Failed to query all files: $err')
// 根据hash获取所有匹配的文件
pub fn (dm DatabaseManager) get_files_by_hash(file_hash string) ![]FileInfo {
rows := dm.db.exec("SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info WHERE file_hash = '${file_hash}'") or {
return error('Failed to query files: ${err}')
}
mut files := []FileInfo{}
for row in rows {
files << FileInfo{
file_uuid: row.vals[0]
file_hash: row.vals[1]
file_name: row.vals[2]
file_size: row.vals[3].int()
file_type: row.vals[4]
created_at: row.vals[5].int()
updated_at: row.vals[6].int()
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return files
}
// 更新文件信息
pub fn (mut dm DatabaseManager) update_file(file_uuid string, file_name string, file_type string, metadata string) !FileInfo {
now := time.now().unix()
dm.db.exec("UPDATE file_info SET file_name = '${escape_sql(file_name)}', file_type = '${file_type}', metadata = '${escape_sql(metadata)}', updated_at = ${now} WHERE file_uuid = '${file_uuid}'") or {
return error('Failed to update file info: ${err}')
}
return dm.get_file_by_uuid(file_uuid)
}
// 删除文件信息
pub fn (mut db DatabaseManager) delete_file(file_uuid string) ! {
db.db.exec('DELETE FROM file_info WHERE file_uuid = "$file_uuid"') or {
return error('Failed to delete file info: $err')
pub fn (mut dm DatabaseManager) delete_file(file_uuid string) ! {
dm.db.exec("DELETE FROM file_info WHERE file_uuid = '${file_uuid}'") or {
return error('Failed to delete file info: ${err}')
}
}
// 检查文件是否存在
pub fn (dm DatabaseManager) file_exists(file_uuid string) bool {
rows := dm.db.exec("SELECT 1 FROM file_info WHERE file_uuid = '${file_uuid}' LIMIT 1") or {
return false
}
return rows.len > 0
}
// 分页列表查询
pub fn (dm DatabaseManager) list_files(options FileListOptions) !FileListResult {
mut where_clauses := []string{}
if options.bucket != '' {
where_clauses << "bucket = '${options.bucket}'"
}
if options.prefix != '' {
where_clauses << "object_key LIKE '${escape_sql(options.prefix)}%'"
}
if options.storage_type != '' {
where_clauses << "storage_type = '${options.storage_type}'"
}
where_sql := if where_clauses.len > 0 { 'WHERE ' + where_clauses.join(' AND ') } else { '' }
order_dir := if options.order_desc { 'DESC' } else { 'ASC' }
order_sql := 'ORDER BY ${options.order_by} ${order_dir}'
// 获取总数
count_rows := dm.db.exec('SELECT COUNT(*) FROM file_info ${where_sql}') or {
return error('Failed to count files: ${err}')
}
total_count := if count_rows.len > 0 { count_rows[0].vals[0].int() } else { 0 }
// 获取分页数据
limit := if options.limit > 0 { options.limit } else { 100 }
rows := dm.db.exec('SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info ${where_sql} ${order_sql} LIMIT ${limit} OFFSET ${options.offset}') or {
return error('Failed to list files: ${err}')
}
mut files := []FileInfo{}
for row in rows {
files << FileInfo{
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return FileListResult{
files: files
total_count: total_count
has_more: options.offset + files.len < total_count
}
}
// 获取所有文件
pub fn (dm DatabaseManager) get_all_files() ![]FileInfo {
result := dm.list_files(FileListOptions{ limit: 10000 })!
return result.files
}
// ============ 分片上传状态管理 ============
// 创建分片上传记录
pub fn (mut dm DatabaseManager) create_multipart_upload(upload MultipartUpload) !MultipartUpload {
now := time.now().unix()
upload_id := if upload.upload_id != '' { upload.upload_id } else { generate_db_upload_id() }
file_uuid := if upload.file_uuid != '' { upload.file_uuid } else { generate_file_uuid() }
dm.db.exec("INSERT INTO multipart_uploads (upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at) VALUES ('${upload_id}', '${file_uuid}', '${escape_sql(upload.file_name)}', ${upload.file_size}, ${upload.chunk_size}, ${upload.total_chunks}, '${upload.bucket}', '${escape_sql(upload.object_key)}', '${upload.content_type}', 'uploading', ${now}, ${now})") or {
return error('Failed to create multipart upload: ${err}')
}
return MultipartUpload{
id: 0
upload_id: upload_id
file_uuid: file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
bucket: upload.bucket
object_key: upload.object_key
content_type: upload.content_type
status: 'uploading'
created_at: now
updated_at: now
}
}
// 获取分片上传状态
pub fn (dm DatabaseManager) get_multipart_upload(upload_id string) !MultipartUpload {
rows := dm.db.exec("SELECT id, upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at FROM multipart_uploads WHERE upload_id = '${upload_id}'") or {
return error('Failed to query multipart upload: ${err}')
}
if rows.len > 0 {
row := rows[0]
return MultipartUpload{
id: row.vals[0].int()
upload_id: row.vals[1]
file_uuid: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
chunk_size: row.vals[5].int()
total_chunks: row.vals[6].int()
bucket: row.vals[7]
object_key: row.vals[8]
content_type: row.vals[9]
status: row.vals[10]
created_at: row.vals[11].i64()
updated_at: row.vals[12].i64()
}
}
return error('Multipart upload not found')
}
// 更新分片上传状态
pub fn (mut dm DatabaseManager) update_multipart_status(upload_id string, status string) ! {
now := time.now().unix()
dm.db.exec("UPDATE multipart_uploads SET status = '${status}', updated_at = ${now} WHERE upload_id = '${upload_id}'") or {
return error('Failed to update multipart status: ${err}')
}
}
// 删除分片上传记录
pub fn (mut dm DatabaseManager) delete_multipart_upload(upload_id string) ! {
// 先删除分片记录
dm.db.exec("DELETE FROM uploaded_parts WHERE upload_id = '${upload_id}'") or {
return error('Failed to delete uploaded parts: ${err}')
}
// 再删除上传记录
dm.db.exec("DELETE FROM multipart_uploads WHERE upload_id = '${upload_id}'") or {
return error('Failed to delete multipart upload: ${err}')
}
}
// 列出所有进行中的分片上传
pub fn (dm DatabaseManager) list_pending_multipart_uploads() ![]MultipartUpload {
rows := dm.db.exec("SELECT id, upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at FROM multipart_uploads WHERE status = 'uploading' ORDER BY created_at DESC") or {
return error('Failed to list pending uploads: ${err}')
}
mut uploads := []MultipartUpload{}
for row in rows {
uploads << MultipartUpload{
id: row.vals[0].int()
upload_id: row.vals[1]
file_uuid: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
chunk_size: row.vals[5].int()
total_chunks: row.vals[6].int()
bucket: row.vals[7]
object_key: row.vals[8]
content_type: row.vals[9]
status: row.vals[10]
created_at: row.vals[11].i64()
updated_at: row.vals[12].i64()
}
}
return uploads
}
// ============ 分片记录管理 ============
// 记录已上传的分片
pub fn (mut dm DatabaseManager) record_uploaded_part(upload_id string, part_number int, etag string, size i64) !UploadedPart {
now := time.now().unix()
// 使用 INSERT OR REPLACE 来处理重复上传的分片
dm.db.exec("INSERT OR REPLACE INTO uploaded_parts (upload_id, part_number, etag, size, uploaded_at) VALUES ('${upload_id}', ${part_number}, '${etag}', ${size}, ${now})") or {
return error('Failed to record uploaded part: ${err}')
}
// 更新分片上传的更新时间
dm.db.exec("UPDATE multipart_uploads SET updated_at = ${now} WHERE upload_id = '${upload_id}'") or {}
return UploadedPart{
id: 0
upload_id: upload_id
part_number: part_number
etag: etag
size: size
uploaded_at: now
}
}
// 获取已上传的分片列表
pub fn (dm DatabaseManager) get_uploaded_parts(upload_id string) ![]UploadedPart {
rows := dm.db.exec("SELECT id, upload_id, part_number, etag, size, uploaded_at FROM uploaded_parts WHERE upload_id = '${upload_id}' ORDER BY part_number ASC") or {
return error('Failed to get uploaded parts: ${err}')
}
mut parts := []UploadedPart{}
for row in rows {
parts << UploadedPart{
id: row.vals[0].int()
upload_id: row.vals[1]
part_number: row.vals[2].int()
etag: row.vals[3]
size: row.vals[4].i64()
uploaded_at: row.vals[5].i64()
}
}
return parts
}
// 获取已上传分片数量
pub fn (dm DatabaseManager) get_uploaded_parts_count(upload_id string) int {
rows := dm.db.exec("SELECT COUNT(*) FROM uploaded_parts WHERE upload_id = '${upload_id}'") or {
return 0
}
if rows.len > 0 {
return rows[0].vals[0].int()
}
return 0
}
// 检查分片是否已上传
pub fn (dm DatabaseManager) is_part_uploaded(upload_id string, part_number int) bool {
rows := dm.db.exec("SELECT 1 FROM uploaded_parts WHERE upload_id = '${upload_id}' AND part_number = ${part_number} LIMIT 1") or {
return false
}
return rows.len > 0
}
// 获取上传进度(已上传分片数 / 总分片数)
pub fn (dm DatabaseManager) get_upload_progress(upload_id string) !(int, int) {
upload := dm.get_multipart_upload(upload_id)!
uploaded_count := dm.get_uploaded_parts_count(upload_id)
return uploaded_count, upload.total_chunks
}
// 删除分片记录
pub fn (mut dm DatabaseManager) delete_uploaded_part(upload_id string, part_number int) ! {
dm.db.exec("DELETE FROM uploaded_parts WHERE upload_id = '${upload_id}' AND part_number = ${part_number}") or {
return error('Failed to delete uploaded part: ${err}')
}
}
// ============ 辅助函数 ============
// SQL 字符串转义
fn escape_sql(s string) string {
return s.replace("'", "''")
}
// 关闭数据库连接
pub fn (mut db DatabaseManager) close() {
db.db.close() or { }
}
pub fn (mut dm DatabaseManager) close() {
dm.db.close() or {}
}
// ============ 向后兼容的接口 ============
// 插入或更新文件(兼容旧接口,支持扩展参数)
pub fn (mut dm DatabaseManager) insert_or_update_file(file_hash string, file_name string, file_size i64, file_type string, storage_type string, bucket string, object_key string) !FileInfo {
// 检查是否已存在相同的 file_hash 和 bucket/object_key
existing := dm.get_file_by_hash(file_hash) or { FileInfo{} }
if existing.file_uuid != '' && existing.bucket == bucket && existing.object_key == object_key {
// 更新现有记录
return dm.update_file(existing.file_uuid, file_name, file_type, existing.metadata)
}
// 创建新记录
return dm.insert_file(FileInfo{
file_hash: file_hash
file_name: file_name
file_size: file_size
file_type: file_type
storage_type: storage_type
bucket: bucket
object_key: object_key
metadata: ''
})
}
// 简化版插入或更新文件(完全向后兼容旧接口)
pub fn (mut dm DatabaseManager) insert_or_update_file_simple(file_hash string, file_name string, file_size i64, file_type string) !FileInfo {
return dm.insert_or_update_file(file_hash, file_name, file_size, file_type, 'local', 'default', file_name)
}
// 根据 bucket 和 object_key 获取文件
pub fn (dm DatabaseManager) get_file_by_key(bucket string, object_key string) !FileInfo {
rows := dm.db.exec("SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info WHERE bucket = '${bucket}' AND object_key = '${escape_sql(object_key)}'") or {
return error('Failed to query file info: ${err}')
}
if rows.len > 0 {
row := rows[0]
return FileInfo{
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return error('File not found')
}
+115
View File
@@ -0,0 +1,115 @@
# v-hono 高并发优化指南
## 概述
v-hono 使用 uSockets 作为高性能网络后端,经过优化后可以稳定支持 **10,000+ 并发连接**。
## 性能测试结果
| 并发数 | RPS | 平均延迟 | P99延迟 | 成功率 |
|--------|-----|----------|---------|--------|
| 5,000 | 81,524 | 60.77ms | 295.17ms | 100% |
| 6,000 | 56,290 | 98.46ms | 557.47ms | 100% |
| 7,000 | 42,895 | 130.04ms | 824.80ms | 100% |
| 8,000 | 30,537 | 165.27ms | 1058.05ms | 100% |
| 9,000 | 28,693 | 215.86ms | 1422.67ms | 100% |
| 10,000 | 24,198 | 240.90ms | 1602.10ms | 100% |
测试环境:macOS (Apple Silicon M3 Max)
**重要提示**:每次测试后需等待 60 秒让 TIME_WAIT 连接释放,否则会影响下一次测试结果。
## 关键优化
### 1. uSockets backlog 参数
原始 uSockets 库的 `listen()` backlog 参数硬编码为 512,限制了高并发能力。
**修复位置**:`v-hono/usockets/src/bsd.c` (第 532 行和第 578 行)
```c
// 修改前
listen(listenFd, 512)
// 修改后
listen(listenFd, 16384)
```
修改后的源码保存在 `v-hono/usockets/src/bsd.c`,编译后的库文件在 `v-hono/lib/libusockets_full.a`。
**重新编译**:运行 `v-hono/usockets/build.sh` 脚本。
### 2. 系统参数配置
macOS 需要调整以下系统参数:
```bash
# 查看当前值
sysctl kern.ipc.somaxconn
# 临时修改(重启后失效)
sudo sysctl -w kern.ipc.somaxconn=8192
# 永久修改(添加到 /etc/sysctl.conf)
kern.ipc.somaxconn=8192
```
**注意**:实际 backlog 值会被 `kern.ipc.somaxconn` 截断,所以系统参数也需要相应调整。
## 重新编译 uSockets(可选)
如果需要自定义 backlog 值,可以重新编译 uSockets:
```bash
# 1. 克隆 uSockets
git clone https://github.com/uNetworking/uSockets.git
# 2. 修改 src/bsd.c 中的 listen 调用
# 将 listen(listenFd, 512) 改为 listen(listenFd, 16384)
# 3. 编译(macOS with libuv)
WITH_LIBUV=1 CFLAGS="-I/opt/homebrew/include" make
# 4. 复制到 v-hono
cp uSockets.a /path/to/v-hono/lib/libusockets_full.a
# 5. 重新编译 v-hono 应用
v -enable-globals -prod -o your_app your_app.v
```
## 使用建议
1. **生产环境**:建议使用 uSockets 后端(`app.listen_usockets(port)`)
2. **系统调优**:确保 `kern.ipc.somaxconn` >= 8192
3. **文件描述符**:服务器和客户端都需要设置 `ulimit -n 65535`
4. **启动命令**:`ulimit -n 65535 && ./your_server`
## 高并发测试
运行高并发测试:
```bash
# 启动服务器
ulimit -n 65535 && ./bench_server_usockets
# 运行测试 (5000-10000 并发)
ulimit -n 65535 && go run v-hono/tests/test_high_concurrency.go
# 单独测试某个级别
ulimit -n 65535 && go run v-hono/tests/test_high_concurrency.go 8000
```
## 问题排查
如果高并发测试失败,检查:
1. 服务器是否正常响应:`curl http://localhost:8080/health`
2. 系统参数:`sysctl kern.ipc.somaxconn`
3. 文件描述符限制:`ulimit -n`
4. 测试客户端是否有端口耗尽问题
## 版本信息
- uSockets backlog: 16384
- 测试日期: 2026-01-03
- 测试通过最大并发: 10,000(不使用渐进式启动)
+1 -1
View File
@@ -11,7 +11,7 @@
#### 使用方式
```v
import hono
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
+1 -1
View File
@@ -1,6 +1,6 @@
module main
import hono
import meiseayoung.hono
import net.http
import x.json2
+1 -1
View File
@@ -4,7 +4,7 @@
module main
import net.http
import hono
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
+1 -1
View File
@@ -9,7 +9,7 @@
module main
import time
import hono
import meiseayoung.hono
import net.http
// ============================================
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
fn main() {
+1 -1
View File
@@ -3,7 +3,7 @@ module main
import net.http
import time
import hono
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
+321
View File
@@ -0,0 +1,321 @@
// middleware_demo.v - 中间件使用示例
// 本示例展示了 v-hono 框架所有内置中间件的基本用法
module main
import hono
import net.http
import time
fn main() {
mut app := hono.Hono.new()
// ============================================================================
// 1. CORS 中间件示例
// ============================================================================
// 基本用法:允许所有来源
app.use(hono.cors())
// 高级配置示例(注释掉以避免重复)
// app.use(hono.cors(hono.CorsOptions{
// origin: 'https://example.com' // 只允许特定域名
// credentials: true // 允许携带凭证
// max_age: 600 // 预检请求缓存 10 分钟
// allow_methods: ['GET', 'POST', 'PUT', 'DELETE']
// allow_headers: ['Content-Type', 'Authorization']
// }))
// ============================================================================
// 2. 压缩中间件示例
// ============================================================================
// 使用 gzip 压缩(默认)
app.use(hono.gzip())
// 或者使用 deflate 压缩
// app.use(hono.deflate_compress())
// 自定义压缩配置
// app.use(hono.compress(hono.CompressOptions{
// encoding: .gzip
// threshold: 2048 // 只压缩大于 2KB 的响应
// level: 9 // 最高压缩级别
// }))
// ============================================================================
// 3. 安全响应头中间件示例
// ============================================================================
app.use(hono.secure_headers())
// ============================================================================
// 4. 请求 ID 中间件示例
// ============================================================================
app.use(hono.request_id())
// ============================================================================
// 5. 请求计时中间件示例
// ============================================================================
app.use(hono.timing())
// ============================================================================
// 6. 限流中间件示例
// ============================================================================
// 创建内存存储
store := hono.MemoryStore.new()
// 应用限流中间件:每分钟最多 100 个请求
app.use(hono.rate_limiter(hono.RateLimitOptions{
store: store
window_ms: 60000 // 1 分钟
limit: 100 // 最多 100 个请求
headers: true // 添加限流响应头
}))
// ============================================================================
// 路由示例
// ============================================================================
// 基本路由
app.get('/', fn (mut c hono.Context) http.Response {
return c.json('{"message": "Welcome to v-hono middleware demo!"}')
})
// 获取请求信息
app.get('/info', fn (mut c hono.Context) http.Response {
// 获取请求 ID
request_id := c.get('request_id') or { 'unknown' }
// 获取客户端 IP
client_ip := c.get_client_ip()
return c.json('{"request_id": "${request_id}", "client_ip": "${client_ip}"}')
})
// ============================================================================
// 7. Cookie Helper 示例
// ============================================================================
// 设置 Cookie
app.get('/cookie/set', fn (mut c hono.Context) http.Response {
// 设置普通 Cookie
hono.set_cookie(mut c, 'session_id', 'abc123', hono.CookieOptions{
http_only: true
secure: false // 开发环境设为 false
max_age: 3600 // 1 小时
path: '/'
})
return c.json('{"message": "Cookie set successfully"}')
})
// 获取 Cookie
app.get('/cookie/get', fn (mut c hono.Context) http.Response {
if session_id := hono.get_cookie(c, 'session_id') {
return c.json('{"session_id": "${session_id}"}')
}
return c.json('{"error": "Cookie not found"}')
})
// 获取所有 Cookie
app.get('/cookie/all', fn (mut c hono.Context) http.Response {
cookies := hono.get_all_cookies(c)
mut parts := []string{}
for name, value in cookies {
parts << '"${name}": "${value}"'
}
return c.json('{${parts.join(", ")}}')
})
// 删除 Cookie
app.get('/cookie/delete', fn (mut c hono.Context) http.Response {
hono.delete_cookie(mut c, 'session_id')
return c.json('{"message": "Cookie deleted"}')
})
// 签名 Cookie 示例
app.get('/cookie/signed/set', fn (mut c hono.Context) http.Response {
secret := 'my-secret-key-for-signing'
hono.set_signed_cookie(mut c, 'user_data', 'user123', secret) or {
c.status(500)
return c.json('{"error": "Failed to set signed cookie"}')
}
return c.json('{"message": "Signed cookie set successfully"}')
})
app.get('/cookie/signed/get', fn (mut c hono.Context) http.Response {
secret := 'my-secret-key-for-signing'
user_data := hono.get_signed_cookie(c, 'user_data', secret) or {
c.status(400)
return c.json('{"error": "Invalid or missing signed cookie"}')
}
return c.json('{"user_data": "${user_data}"}')
})
// ============================================================================
// 8. JWT 中间件示例
// ============================================================================
// 生成 JWT Token
app.post('/auth/login', fn (mut c hono.Context) http.Response {
// 在实际应用中,这里应该验证用户凭证
secret := 'my-jwt-secret-key'
// 创建 JWT payload
payload := hono.JwtPayload{
sub: 'user123'
iss: 'v-hono-demo'
exp: time.now().unix() + 3600 // 1 小时后过期
iat: time.now().unix()
claims: {
'role': 'admin'
'name': 'John Doe'
}
}
// 签名 JWT
token := hono.sign_jwt(payload, secret, .hs256) or {
c.status(500)
return c.json('{"error": "Failed to generate token"}')
}
return c.json('{"token": "${token}"}')
})
// 验证 JWT Token(手动验证示例)
app.get('/auth/verify', fn (mut c hono.Context) http.Response {
secret := 'my-jwt-secret-key'
// 从 Authorization 头获取 token
auth_header := c.req.header.get_custom('Authorization') or {
c.status(401)
return c.json('{"error": "Missing Authorization header"}')
}
if !auth_header.starts_with('Bearer ') {
c.status(401)
return c.json('{"error": "Invalid Authorization format"}')
}
token := auth_header[7..]
// 验证 token
payload := hono.verify_jwt(token, secret, .hs256) or {
c.status(401)
return c.json('{"error": "Invalid token: ${err}"}')
}
return c.json('{"sub": "${payload.sub}", "iss": "${payload.iss}"}')
})
// ============================================================================
// 9. Bearer Auth 中间件示例(保护特定路由)
// ============================================================================
// 创建受保护的子应用
mut protected_app := hono.Hono.new()
// 应用 Bearer Auth 中间件
protected_app.use(hono.bearer(hono.BearerAuthOptions{
token: 'my-api-token' // 简单的静态 token
realm: 'Protected API'
}))
protected_app.get('/data', fn (mut c hono.Context) http.Response {
// 获取已验证的 token
token := hono.get_bearer_token(c) or { 'unknown' }
return c.json('{"message": "Protected data", "token": "${token}"}')
})
// 挂载受保护的路由
app.route('/api', mut protected_app)
// ============================================================================
// 10. 请求验证示例
// ============================================================================
// JSON body 验证
app.post('/users',
hono.validate_json(hono.v_object({
'name': hono.v_string().required().min(2).max(50)
'email': hono.v_string().required().pattern(r'^[\w\.-]+@[\w\.-]+\.\w+$')
'age': hono.v_int().min(0).max(150)
})),
fn (mut c hono.Context) http.Response {
// 获取验证后的数据
data := hono.get_validated_data(c)
name := data['name'] or { '' }
email := data['email'] or { '' }
return c.json('{"message": "User created", "name": "${name}", "email": "${email}"}')
}
)
// Query 参数验证
app.get('/search',
hono.validate_query(hono.v_object({
'q': hono.v_string().required().min(1)
'page': hono.v_int().min(1)
'size': hono.v_int().min(1).max(100)
})),
fn (mut c hono.Context) http.Response {
q := hono.get_validated_field(c, 'q') or { '' }
page := hono.get_validated_field(c, 'page') or { '1' }
return c.json('{"query": "${q}", "page": ${page}}')
}
)
// ============================================================================
// 11. 中间件组合示例
// ============================================================================
// 组合多个中间件
combined := hono.combine_middlewares([
hono.cors_middleware(),
hono.secure_headers(),
hono.timing(),
])
// 创建使用组合中间件的子应用
mut combined_app := hono.Hono.new()
combined_app.use(combined)
combined_app.get('/test', fn (mut c hono.Context) http.Response {
duration := c.get('request_duration_ms') or { '0' }
return c.json('{"message": "Combined middleware test", "duration_ms": "${duration}"}')
})
app.route('/combined', mut combined_app)
// ============================================================================
// 启动服务器
// ============================================================================
println('=== v-hono Middleware Demo ===')
println('')
println('Available endpoints:')
println(' GET / - Welcome message')
println(' GET /info - Request info (ID, IP)')
println('')
println('Cookie endpoints:')
println(' GET /cookie/set - Set a cookie')
println(' GET /cookie/get - Get a cookie')
println(' GET /cookie/all - Get all cookies')
println(' GET /cookie/delete - Delete a cookie')
println(' GET /cookie/signed/set - Set a signed cookie')
println(' GET /cookie/signed/get - Get a signed cookie')
println('')
println('Auth endpoints:')
println(' POST /auth/login - Get JWT token')
println(' GET /auth/verify - Verify JWT token')
println(' GET /api/data - Protected endpoint (Bearer token: my-api-token)')
println('')
println('Validation endpoints:')
println(' POST /users - Create user (JSON body validation)')
println(' GET /search?q=xxx - Search (query validation)')
println('')
println('Combined middleware:')
println(' GET /combined/test - Test combined middlewares')
println('')
println('Starting server on http://localhost:3000')
println('')
app.listen(':3000')
}
+24
View File
@@ -0,0 +1,24 @@
module main
import net.http
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/', fn (mut c hono.Context) http.Response {
return c.text('Hello from V-Hono + picoev!')
})
app.get('/json', fn (mut c hono.Context) http.Response {
return c.json('{"message": "Hello, JSON!"}')
})
app.get('/users/:id', fn (mut c hono.Context) http.Response {
user_id := c.params['id'] or { 'unknown' }
return c.json('{"user_id": "${user_id}"}')
})
// 使用默认 picoev 后端
app.listen(':3009')
}
+1 -1
View File
@@ -1,6 +1,6 @@
module main
import hono
import meiseayoung.hono
import net.http
fn main() {
+1 -1
View File
@@ -2,7 +2,7 @@
module main
import net.http
import hono
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
@@ -12,7 +12,7 @@
// ```
import net.http
import hono
import meiseayoung.hono
fn main() {
println('🚀 V-Hono 路由分组示例启动中...')
@@ -2,7 +2,7 @@
// 演示新功能:子应用中间件继承、all() 方法、notFound/onError 处理器
import net.http
import hono
import meiseayoung.hono
fn main() {
println('🚀 V-Hono 路由分组增强示例启动中...')
+1 -1
View File
@@ -4,7 +4,7 @@
module main
import hono
import meiseayoung.hono
import net.http
fn main() {
+1 -1
View File
@@ -4,7 +4,7 @@
module main
import hono
import meiseayoung.hono
import net.http
fn main() {
+554
View File
@@ -0,0 +1,554 @@
// SSE Streaming Demo - Server-Sent Events Example for v-hono
// This example demonstrates all SSE streaming helper functions:
// - stream() - Basic binary streaming
// - stream_text() - Text streaming
// - stream_sse() - Server-Sent Events streaming
//
// Run with: v -enable-globals run examples/sse_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use curl to test the endpoints:
// curl http://127.0.0.1:3000/stream
// curl http://127.0.0.1:3000/stream-text
// curl http://127.0.0.1:3000/sse
//
// Features demonstrated:
// - Basic binary streaming with stream()
// - Text streaming with stream_text()
// - SSE event streaming with stream_sse()
// - Error handling with custom error handlers
// - SSE event fields (data, event, id, retry)
// - Multi-line data in SSE events
// - Sleep/delay between stream writes
// - onAbort callback for client disconnection
module main
import net.http
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Static HTML page for testing SSE
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
return c.html(get_sse_test_page())
})
// =========================================================================
// Basic Binary Streaming Example
// =========================================================================
// Basic stream - demonstrates binary data streaming
// Sets Transfer-Encoding: chunked header
app.get('/stream', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback for client disconnection
stream.on_abort(fn () {
println('[Stream] Client disconnected')
})
// Stream binary data in chunks
for i in 0 .. 5 {
data := 'Chunk ${i + 1}: Hello from binary stream!\n'
stream.write(data.bytes())!
stream.sleep(500) // 500ms delay between chunks
}
stream.write('Stream complete!\n'.bytes())!
})
})
// =========================================================================
// Text Streaming Example
// =========================================================================
// Text stream - demonstrates text data streaming
// Sets Content-Type: text/plain; charset=utf-8
// Sets Transfer-Encoding: chunked
// Sets X-Content-Type-Options: nosniff
app.get('/stream-text', fn (mut c hono.Context) http.Response {
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[StreamText] Client disconnected')
})
// Stream text data with writeln (adds newline automatically)
stream.writeln('=== Text Streaming Demo ===')!
stream.sleep(300)
for i in 0 .. 5 {
stream.write_string('Processing item ${i + 1}... ')!
stream.sleep(200)
stream.writeln('Done!')!
stream.sleep(300)
}
stream.writeln('')!
stream.writeln('All items processed successfully!')!
})
})
// =========================================================================
// SSE (Server-Sent Events) Streaming Example
// =========================================================================
// SSE stream - demonstrates Server-Sent Events
// Sets Content-Type: text/event-stream
// Sets Cache-Control: no-cache
// Sets Connection: keep-alive
app.get('/sse', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[SSE] Client disconnected')
})
// Send initial connection event
stream.write_sse(hono.SSEEvent{
data: 'Connected to SSE stream'
event: 'connect'
id: '0'
})!
// Send periodic updates
for i in 1 .. 6 {
stream.sleep(1000) // 1 second delay
stream.write_sse(hono.SSEEvent{
data: 'Update ${i}: Current time is ${get_timestamp()}'
event: 'update'
id: '${i}'
})!
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Stream completed'
event: 'complete'
id: '999'
})!
})
})
// =========================================================================
// SSE with Multi-line Data Example
// =========================================================================
// SSE with multi-line data - demonstrates multi-line data handling
app.get('/sse-multiline', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with multi-line data
stream.write_sse(hono.SSEEvent{
data: 'Line 1: First line of data\nLine 2: Second line of data\nLine 3: Third line of data'
event: 'multiline'
id: '1'
})!
stream.sleep(1000)
// Send JSON data (multi-line formatted)
json_data := '{\n "name": "v-hono",\n "version": "1.0.0",\n "features": ["SSE", "WebSocket", "Streaming"]\n}'
stream.write_sse(hono.SSEEvent{
data: json_data
event: 'json'
id: '2'
})!
})
})
// =========================================================================
// SSE with Retry Field Example
// =========================================================================
// SSE with retry - demonstrates retry field for reconnection
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with retry field (tells client to reconnect after 3 seconds)
stream.write_sse(hono.SSEEvent{
data: 'This event includes a retry field'
event: 'message'
id: '1'
retry: 3000 // 3 seconds
})!
stream.sleep(1000)
// Send another event without retry
stream.write_sse(hono.SSEEvent{
data: 'This event does not include retry'
event: 'message'
id: '2'
})!
})
})
// =========================================================================
// SSE with Error Handling Example
// =========================================================================
// SSE with error handling - demonstrates custom error handler
app.get('/sse-error', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send initial event
stream.write_sse(hono.SSEEvent{
data: 'Starting stream with potential error...'
event: 'start'
id: '1'
})!
stream.sleep(1000)
// Simulate an error condition
// In real applications, this could be a database error, API failure, etc.
should_error := true
if should_error {
return error('Simulated error for demonstration')
}
// This won't be reached due to the error above
stream.write_sse(hono.SSEEvent{
data: 'This message will not be sent'
event: 'message'
id: '2'
})!
}, fn (err IError, mut stream hono.StreamContext) {
// Custom error handler - send error event to client
println('[SSE Error Handler] Error occurred: ${err.msg()}')
// Try to send error event to client before closing
stream.write_sse(hono.SSEEvent{
data: 'Error: ${err.msg()}'
event: 'error'
id: 'error'
}) or {
println('[SSE Error Handler] Failed to send error event: ${err}')
}
})
})
// =========================================================================
// Real-time Counter Example
// =========================================================================
// Real-time counter - demonstrates continuous SSE updates
app.get('/sse-counter', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
mut counter := 0
// Send counter updates every 500ms for 20 iterations
for _ in 0 .. 20 {
if !stream.is_open() {
println('[Counter] Stream closed, stopping')
break
}
counter++
stream.write_sse(hono.SSEEvent{
data: '${counter}'
event: 'counter'
id: '${counter}'
})!
stream.sleep(500)
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Counter finished at ${counter}'
event: 'complete'
id: 'done'
})!
})
})
// =========================================================================
// Pipe Example - Stream file content
// =========================================================================
// Pipe example - demonstrates piping data to stream
app.get('/stream-pipe', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Create some sample data to pipe
sample_data := 'This is sample data that will be piped to the stream.\n'.repeat(5)
// Pipe the data in chunks
chunk_size := 50
mut offset := 0
for offset < sample_data.len {
end := if offset + chunk_size > sample_data.len { sample_data.len } else { offset + chunk_size }
chunk := sample_data[offset..end]
stream.pipe(chunk.bytes())!
offset = end
stream.sleep(100)
}
})
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","streaming_endpoints":["/stream","/stream-text","/sse","/sse-multiline","/sse-retry","/sse-error","/sse-counter","/stream-pipe"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('SSE Streaming Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('Streaming Endpoints:')
println(' http://127.0.0.1:3000/stream - Basic binary streaming')
println(' http://127.0.0.1:3000/stream-text - Text streaming')
println(' http://127.0.0.1:3000/sse - SSE event streaming')
println(' http://127.0.0.1:3000/sse-multiline - SSE with multi-line data')
println(' http://127.0.0.1:3000/sse-retry - SSE with retry field')
println(' http://127.0.0.1:3000/sse-error - SSE with error handling')
println(' http://127.0.0.1:3000/sse-counter - Real-time counter')
println(' http://127.0.0.1:3000/stream-pipe - Pipe data to stream')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('Test with curl:')
println(' curl http://127.0.0.1:3000/stream')
println(' curl http://127.0.0.1:3000/stream-text')
println(' curl http://127.0.0.1:3000/sse')
println('')
println('============================================================')
app.listen(':3000')
}
// get_timestamp - Get current timestamp string
fn get_timestamp() string {
return '${os.execute("echo %TIME%").output.trim_space()}'
}
// get_sse_test_page - Returns HTML test page for SSE streaming
fn get_sse_test_page() string {
sq := "'"
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono SSE Streaming Demo</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #667eea; margin-bottom: 10px; }
h2 { color: #333; font-size: 1.2em; margin: 20px 0 10px; }
.subtitle { color: #666; margin-bottom: 30px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.endpoint { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
.endpoint-url { font-family: monospace; background: #f0f0f0; padding: 8px 12px; border-radius: 4px; flex: 1; min-width: 200px; }
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; transition: background 0.2s; }
.btn-connect { background: #667eea; color: white; }
.btn-connect:hover { background: #5a6fd6; }
.btn-disconnect { background: #e74c3c; color: white; }
.btn-disconnect:hover { background: #c0392b; }
.btn-clear { background: #95a5a6; color: white; }
.btn-clear:hover { background: #7f8c8d; }
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
.status-connected { background: #27ae60; color: white; }
.status-disconnected { background: #e74c3c; color: white; }
.status-connecting { background: #f39c12; color: white; }
.output { height: 200px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; line-height: 1.5; }
.output:empty::before { content: "No messages yet..."; color: #666; }
.msg { margin: 2px 0; padding: 2px 0; }
.msg-event { color: #569cd6; }
.msg-data { color: #ce9178; }
.msg-id { color: #b5cea8; }
.msg-retry { color: #dcdcaa; }
.msg-system { color: #6a9955; font-style: italic; }
.msg-error { color: #f44747; }
.msg-text { color: #d4d4d4; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .endpoint { flex-direction: column; align-items: stretch; } }
</style>
</head>
<body>
<h1>v-hono SSE Streaming Demo</h1>
<p class="subtitle">Test Server-Sent Events and streaming responses</p>
<div class="grid">
<div class="card">
<h2>SSE Event Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/sse</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse${sq}, ${sq}sse-output${sq}, ${sq}sse-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}sse${sq})">Disconnect</button>
<span id="sse-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="sse-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}sse-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Multi-line SSE</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-multiline</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-multiline${sq}, ${sq}multiline-output${sq}, ${sq}multiline-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}multiline${sq})">Disconnect</button>
<span id="multiline-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="multiline-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}multiline-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Real-time Counter</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-counter</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-counter${sq}, ${sq}counter-output${sq}, ${sq}counter-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}counter${sq})">Disconnect</button>
<span id="counter-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="counter-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}counter-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Error Handling</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-error</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-error${sq}, ${sq}error-output${sq}, ${sq}error-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}error${sq})">Disconnect</button>
<span id="error-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="error-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}error-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Text Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream-text</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream-text${sq}, ${sq}text-output${sq}, ${sq}text-status${sq})">Fetch</button>
<span id="text-status" class="status status-disconnected">Ready</span>
</div>
<div id="text-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}text-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Binary Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream${sq}, ${sq}binary-output${sq}, ${sq}binary-status${sq})">Fetch</button>
<span id="binary-status" class="status status-disconnected">Ready</span>
</div>
<div id="binary-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}binary-output${sq})">Clear</button>
</div>
</div>
<script>
var connections = {};
function log(outputId, msg, type) {
var output = document.getElementById(outputId);
var div = document.createElement(${sq}div${sq});
div.className = ${sq}msg msg-${sq} + (type || ${sq}text${sq});
div.textContent = msg;
output.appendChild(div);
output.scrollTop = output.scrollHeight;
}
function setStatus(statusId, status) {
var el = document.getElementById(statusId);
el.textContent = status;
el.className = ${sq}status status-${sq} + status.toLowerCase();
}
function clearOutput(outputId) {
document.getElementById(outputId).innerHTML = ${sq}${sq};
}
function connectSSE(url, outputId, statusId) {
var key = outputId.replace(${sq}-output${sq}, ${sq}${sq});
if (connections[key]) {
connections[key].close();
}
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Connecting to ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
var es = new EventSource(url);
connections[key] = es;
es.onopen = function() {
setStatus(statusId, ${sq}Connected${sq});
log(outputId, ${sq}Connected!${sq}, ${sq}system${sq});
};
es.onmessage = function(e) {
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
};
es.onerror = function() {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Connection closed${sq}, ${sq}system${sq});
connections[key] = null;
};
// Listen for custom event types
[${sq}connect${sq}, ${sq}update${sq}, ${sq}complete${sq}, ${sq}multiline${sq}, ${sq}json${sq}, ${sq}message${sq}, ${sq}counter${sq}, ${sq}start${sq}, ${sq}error${sq}].forEach(function(type) {
es.addEventListener(type, function(e) {
log(outputId, ${sq}event: ${sq} + type, ${sq}event${sq});
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
if (e.lastEventId) log(outputId, ${sq}id: ${sq} + e.lastEventId, ${sq}id${sq});
});
});
}
function disconnectSSE(key) {
if (connections[key]) {
connections[key].close();
connections[key] = null;
setStatus(key + ${sq}-status${sq}, ${sq}Disconnected${sq});
log(key + ${sq}-output${sq}, ${sq}Disconnected by user${sq}, ${sq}system${sq});
}
}
async function fetchStream(url, outputId, statusId) {
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Fetching ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
try {
var response = await fetch(url);
var reader = response.body.getReader();
var decoder = new TextDecoder();
setStatus(statusId, ${sq}Connected${sq});
while (true) {
var result = await reader.read();
if (result.done) break;
var text = decoder.decode(result.value, {stream: true});
log(outputId, text, ${sq}text${sq});
}
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Stream complete${sq}, ${sq}system${sq});
} catch (err) {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Error: ${sq} + err.message, ${sq}error${sq});
}
}
</script>
</body>
</html>'
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
import os
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
import os
import time
+337
View File
@@ -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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
// 测试服务器 - 用于高并发测试
module main
import hono
import meiseayoung.hono
import net.http
fn main() {
+24
View File
@@ -0,0 +1,24 @@
module main
import net.http
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/', fn (mut c hono.Context) http.Response {
return c.text('Hello from V-Hono + uSockets!')
})
app.get('/json', fn (mut c hono.Context) http.Response {
return c.json('{"message": "Hello, JSON!"}')
})
app.get('/users/:id', fn (mut c hono.Context) http.Response {
user_id := c.params['id'] or { 'unknown' }
return c.json('{"user_id": "${user_id}"}')
})
// 使用 uSockets 后端
app.listen_usockets(3008)
}
+37
View File
@@ -0,0 +1,37 @@
// uSockets 测试服务器 (本地模块版本)
// 编译方式:
// 方式1 (推荐): 将 v-hono 安装到 ~/.vmodules 后使用
// 方式2: 直接编译整个目录
//
// 运行: ./usockets_server
module main
import net.http
// 由于 V 语言模块系统限制,这里直接包含必要的代码
// 实际使用时应该 import meiseayoung.hono
fn main() {
println('=== uSockets 服务器测试 ===')
println('')
println('由于 V 语言模块导入限制,无法直接从 examples 目录导入父目录模块。')
println('')
println('请使用以下方式测试 uSockets 服务器:')
println('')
println('方式 1: 安装 v-hono 到 vpm')
println(' v install meiseayoung.hono')
println(' v run tests/test_usockets_server.v')
println('')
println('方式 2: 使用符号链接')
println(' ln -s $(pwd) ~/.vmodules/hono')
println(' v run tests/test_usockets_server.v')
println('')
println('方式 3: 直接编译测试')
println(' v -shared v-hono/')
println(' # 如果编译成功,说明 uSockets 模块正常工作')
println('')
// 验证编译成功
println('✅ 编译成功!uSockets 模块已移除全局变量依赖。')
}
+336
View File
@@ -0,0 +1,336 @@
// WebSocket Demo - Chat Room Example for v-hono
// This example demonstrates all WebSocket event handlers and configuration options.
//
// Run with: v run examples/websocket_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use a WebSocket client to connect to ws://127.0.0.1:3000/ws/chat/general
//
// Features demonstrated:
// - WebSocket upgrade handling
// - Event handlers (on_open, on_message, on_close, on_error)
// - Route parameters (/ws/chat/:room)
// - Query parameters (?username=xxx)
// - Subprotocol negotiation
// - Custom configuration options (ping interval, max message size, timeout)
// - JSON message handling
// - Binary message handling
// - Middleware integration with WebSocket routes
module main
import net.http
import time
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Middleware - Runs before WebSocket upgrade
// =========================================================================
// Logger middleware - logs all requests including WebSocket upgrades
app.use(fn (mut c hono.Context, next fn (mut hono.Context) http.Response) http.Response {
start := time.now()
response := next(mut c)
duration := time.since(start)
println('[${time.now().format_ss()}] ${c.req.method} ${c.path} - ${response.status_code} (${duration})')
return response
})
// =========================================================================
// Static HTML page for testing WebSocket
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
// Try to load the HTML file from the examples directory
html_content := os.read_file('examples/websocket_test.html') or {
// Fallback to a simple HTML page if file not found
return c.html(get_simple_test_page())
}
return c.html(html_content)
})
// =========================================================================
// Basic WebSocket Echo Example
// =========================================================================
// Simple echo WebSocket - echoes back any message received
app.ws('/ws/echo', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Echo] Client connected')
ws.send('Welcome to the echo server!') or {
println('[Echo] Failed to send welcome: ${err}')
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Echo] Received binary message: ${event.data_bytes.len} bytes')
ws.send_bytes(event.data_bytes) or {
println('[Echo] Failed to echo binary: ${err}')
}
} else {
println('[Echo] Received: ${event.data}')
ws.send('Echo: ${event.data}') or {
println('[Echo] Failed to echo: ${err}')
}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Echo] Client disconnected - Code: ${event.code}, Reason: ${event.reason}, Clean: ${event.was_clean}')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Echo] Error: ${error}')
}
}
})
// =========================================================================
// Chat Room WebSocket with Route Parameters
// =========================================================================
// Chat room WebSocket - demonstrates route parameters and query parameters
// URL format: /ws/chat/:room?username=xxx
app.ws('/ws/chat/:room', fn (c hono.Context) hono.WSEvents {
// Access route parameters from the HTTP context
room := c.params['room'] or { 'general' }
username := c.query['username'] or { 'anonymous' }
println('[Chat] User "${username}" joining room "${room}"')
return hono.WSEvents{
on_open: fn [room, username] (mut ws hono.WSContext) {
// Access preserved context data
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} connected')
// Send welcome message as JSON
welcome := '{"type":"system","message":"Welcome to room ${actual_room}, ${actual_user}!"}'
ws.send_json(welcome) or {
println('[Chat] Failed to send welcome: ${err}')
}
// Broadcast join notification
join_msg := '{"type":"join","user":"${actual_user}","room":"${actual_room}"}'
ws.send_json(join_msg) or {}
}
on_message: fn [room, username] (event hono.WSMessageEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user}: ${event.data}')
// Echo the message back with metadata
response := '{"type":"message","user":"${actual_user}","room":"${actual_room}","content":"${event.data}"}'
ws.send_json(response) or {
println('[Chat] Failed to send response: ${err}')
}
}
on_close: fn [room, username] (event hono.WSCloseEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} disconnected (code: ${event.code})')
}
on_error: fn [room, username] (error string, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] Error for ${actual_user}: ${error}')
}
}
})
// =========================================================================
// WebSocket with Custom Configuration Options
// =========================================================================
// Configured WebSocket - demonstrates custom options
app.ws('/ws/configured', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Configured] Client connected with custom settings')
ws.send('Connected with custom configuration!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
println('[Configured] Message: ${event.data}')
ws.send('Received: ${event.data}') or {}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Configured] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Configured] Error: ${error}')
}
}
}, hono.WebSocketOptions{
ping_interval: 15000 // Send ping every 15 seconds
max_message_size: 65536 // Max 64KB messages
timeout: 30000 // 30 second timeout
protocols: ['chat', 'json'] // Supported subprotocols
})
// =========================================================================
// WebSocket with Subprotocol Negotiation
// =========================================================================
// Subprotocol WebSocket - demonstrates protocol negotiation
app.ws('/ws/protocol', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Client connected with protocol: ${protocol}')
if protocol == 'json' {
ws.send_json('{"status":"connected","protocol":"json"}') or {}
} else {
ws.send('Connected with protocol: ${protocol}') or {}
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Message (${protocol}): ${event.data}')
if protocol == 'json' {
ws.send_json('{"echo":"${event.data}"}') or {}
} else {
ws.send('Echo: ${event.data}') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Protocol] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Protocol] Error: ${error}')
}
}
}, hono.WebSocketOptions{
protocols: ['json', 'text', 'binary']
})
// =========================================================================
// Binary Data WebSocket
// =========================================================================
// Binary WebSocket - demonstrates binary message handling
app.ws('/ws/binary', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Binary] Client connected')
ws.send('Binary WebSocket ready. Send binary data!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Binary] Received ${event.data_bytes.len} bytes')
// Echo back the binary data
ws.send_bytes(event.data_bytes) or {
println('[Binary] Failed to echo: ${err}')
}
// Also send a text confirmation
ws.send('Received ${event.data_bytes.len} bytes of binary data') or {}
} else {
println('[Binary] Received text: ${event.data}')
ws.send('Please send binary data, not text!') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Binary] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Binary] Error: ${error}')
}
}
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/rooms', fn (mut c hono.Context) http.Response {
return c.json('{"rooms":["general","tech","random"]}')
})
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","websocket_endpoints":["/ws/echo","/ws/chat/:room","/ws/configured","/ws/protocol","/ws/binary"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('WebSocket Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('WebSocket Endpoints:')
println(' ws://127.0.0.1:3000/ws/echo - Simple echo server')
println(' ws://127.0.0.1:3000/ws/chat/:room - Chat room with route params')
println(' ws://127.0.0.1:3000/ws/configured - Custom configuration demo')
println(' ws://127.0.0.1:3000/ws/protocol - Subprotocol negotiation')
println(' ws://127.0.0.1:3000/ws/binary - Binary data handling')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/rooms - List rooms')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('============================================================')
app.listen(':3000')
}
// Simple fallback test page when HTML file is not found
fn get_simple_test_page() string {
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono WebSocket Demo</title>
<style>
body{font-family:sans-serif;max-width:800px;margin:50px auto;padding:20px}
h1{color:#667eea}
.card{background:#f5f5f5;padding:20px;margin:20px 0;border-radius:8px}
input,button{padding:10px;margin:5px;border-radius:4px;border:1px solid #ccc}
button{background:#667eea;color:white;border:none;cursor:pointer}
button:hover{background:#5a6fd6}
#messages{height:200px;overflow-y:auto;background:white;padding:10px;border:1px solid #ccc;border-radius:4px}
.msg{padding:5px;margin:2px 0;border-radius:4px}
.sent{background:#e3f2fd}
.received{background:#e8f5e9}
.system{background:#fff3e0}
</style>
</head>
<body>
<h1>v-hono WebSocket Demo</h1>
<div class="card">
<h3>Connection</h3>
<input type="text" id="url" value="ws://127.0.0.1:3000/ws/echo" style="width:300px">
<button onclick="connect()">Connect</button>
<button onclick="disconnect()">Disconnect</button>
<span id="status">Disconnected</span>
</div>
<div class="card">
<h3>Send Message</h3>
<input type="text" id="msg" placeholder="Type message..." style="width:300px">
<button onclick="send()">Send</button>
</div>
<div class="card">
<h3>Messages</h3>
<div id="messages"></div>
</div>
<script>
var ws=null;
function log(t,c){var m=document.getElementById("messages");m.innerHTML+="<div class=msg "+c+">"+t+"</div>";m.scrollTop=m.scrollHeight}
function connect(){if(ws)return;ws=new WebSocket(document.getElementById("url").value);ws.onopen=function(){document.getElementById("status").textContent="Connected";log("Connected","system")};ws.onmessage=function(e){log("Received: "+e.data,"received")};ws.onclose=function(){document.getElementById("status").textContent="Disconnected";log("Disconnected","system");ws=null};ws.onerror=function(){log("Error","system")}}
function disconnect(){if(ws)ws.close()}
function send(){if(!ws)return;var m=document.getElementById("msg").value;ws.send(m);log("Sent: "+m,"sent");document.getElementById("msg").value=""}
</script>
</body>
</html>'
}
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>v-hono WebSocket Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 {
color: white;
text-align: center;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.card h2 {
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.form-group { margin-bottom: 15px; }
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
input, select {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
.btn-group { display: flex; gap: 10px; flex-wrap: wrap; }
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
button:active { transform: translateY(0); }
.btn-connect { background: #667eea; color: white; }
.btn-disconnect { background: #e74c3c; color: white; }
.btn-send { background: #27ae60; color: white; }
.btn-clear { background: #95a5a6; color: white; }
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.status-disconnected { background: #fee2e2; color: #dc2626; }
.status-connecting { background: #fef3c7; color: #d97706; }
.status-connected { background: #d1fae5; color: #059669; }
#messages {
height: 300px;
overflow-y: auto;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
background: #f8f9fa;
font-family: "Monaco", "Menlo", monospace;
font-size: 13px;
}
.message {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-sent { background: #e3f2fd; border-left: 4px solid #2196f3; }
.message-received { background: #e8f5e9; border-left: 4px solid #4caf50; }
.message-system { background: #fff3e0; border-left: 4px solid #ff9800; }
.message-error { background: #ffebee; border-left: 4px solid #f44336; }
.message-time { color: #999; font-size: 11px; }
.endpoints {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 15px;
}
.endpoint {
padding: 10px;
background: #f0f4ff;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.endpoint:hover { background: #e0e7ff; }
.endpoint code {
display: block;
font-size: 12px;
color: #667eea;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔌 v-hono WebSocket Demo</h1>
<div class="card">
<h2>Connection <span id="status" class="status status-disconnected">Disconnected</span></h2>
<div class="form-group">
<label>WebSocket URL</label>
<input type="text" id="wsUrl" value="ws://127.0.0.1:3000/ws/echo" placeholder="ws://...">
</div>
<div class="form-group">
<label>Quick Connect</label>
<div class="endpoints">
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/echo')">
<strong>Echo Server</strong>
<code>/ws/echo</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/chat/general?username=guest')">
<strong>Chat Room</strong>
<code>/ws/chat/:room</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/configured')">
<strong>Configured</strong>
<code>/ws/configured</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/protocol')">
<strong>Protocol</strong>
<code>/ws/protocol</code>
</div>
</div>
</div>
<div class="btn-group">
<button class="btn-connect" onclick="connect()">Connect</button>
<button class="btn-disconnect" onclick="disconnect()">Disconnect</button>
</div>
</div>
<div class="card">
<h2>Send Message</h2>
<div class="form-group">
<label>Message</label>
<input type="text" id="message" placeholder="Type your message..." onkeypress="if(event.key==='Enter')sendMessage()">
</div>
<div class="btn-group">
<button class="btn-send" onclick="sendMessage()">Send Text</button>
<button class="btn-send" onclick="sendJson()">Send JSON</button>
</div>
</div>
<div class="card">
<h2>Messages <button class="btn-clear" onclick="clearMessages()" style="float:right;padding:6px 12px;">Clear</button></h2>
<div id="messages"></div>
</div>
</div>
<script>
let ws = null;
function setUrl(url) {
document.getElementById('wsUrl').value = url;
}
function updateStatus(status) {
const el = document.getElementById('status');
el.textContent = status;
el.className = 'status status-' + status.toLowerCase();
}
function addMessage(type, text) {
const messages = document.getElementById('messages');
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'message message-' + type;
div.innerHTML = '<span class="message-time">[' + time + ']</span> ' + text;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
addMessage('system', 'Already connected!');
return;
}
const url = document.getElementById('wsUrl').value;
updateStatus('Connecting');
addMessage('system', 'Connecting to ' + url + '...');
try {
ws = new WebSocket(url);
ws.onopen = function() {
updateStatus('Connected');
addMessage('system', 'Connected successfully!');
};
ws.onmessage = function(event) {
addMessage('received', event.data);
};
ws.onclose = function(event) {
updateStatus('Disconnected');
addMessage('system', 'Disconnected (code: ' + event.code + ', reason: ' + (event.reason || 'none') + ')');
ws = null;
};
ws.onerror = function(error) {
addMessage('error', 'WebSocket error occurred');
};
} catch (e) {
addMessage('error', 'Failed to connect: ' + e.message);
updateStatus('Disconnected');
}
}
function disconnect() {
if (ws) {
ws.close(1000, 'User requested disconnect');
addMessage('system', 'Disconnecting...');
} else {
addMessage('system', 'Not connected');
}
}
function sendMessage() {
const input = document.getElementById('message');
const text = input.value.trim();
if (!text) {
addMessage('error', 'Please enter a message');
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('error', 'Not connected!');
return;
}
ws.send(text);
addMessage('sent', text);
input.value = '';
}
function sendJson() {
const input = document.getElementById('message');
const text = input.value.trim() || 'Hello';
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('error', 'Not connected!');
return;
}
const json = JSON.stringify({ type: 'message', content: text, timestamp: Date.now() });
ws.send(json);
addMessage('sent', json);
input.value = '';
}
function clearMessages() {
document.getElementById('messages').innerHTML = '';
}
</script>
</body>
</html>
+26 -11
View File
@@ -27,10 +27,12 @@ pub mut:
static_routes map[string]IHandler
static_route_results map[string]ContextRouteMatch // 预分配的静态路由结果
precompiled_routes []PrecompiledRoute
lru_cache ContextLRUCache // 使用 LRU 缓存替代简单 map
fast_cache FastRouteCache // 使用高性能缓存替代 LRU
lru_cache ContextLRUCache // 保留 LRU 缓存用于兼容
regex_cache map[string]FastCompiledRegex // 正则表达式缓存
cache_enabled bool = true
sort_enabled bool = true // 是否启用路由排序
use_fast_cache bool = true // 是否使用高性能缓存
}
// 创建快速路由器
@@ -39,8 +41,10 @@ pub fn FastRouter.new() FastRouter {
static_routes: map[string]IHandler{}
static_route_results: map[string]ContextRouteMatch{}
precompiled_routes: []PrecompiledRoute{}
lru_cache: ContextLRUCache.new(1000) // 默认1000条缓存
fast_cache: FastRouteCache.new(10000) // 高性能缓存,10000条
lru_cache: ContextLRUCache.new(1000) // LRU 缓存保留兼容
regex_cache: map[string]FastCompiledRegex{}
use_fast_cache: true
}
}
@@ -50,8 +54,10 @@ pub fn FastRouter.new_with_cache_size(cache_size int) FastRouter {
static_routes: map[string]IHandler{}
static_route_results: map[string]ContextRouteMatch{}
precompiled_routes: []PrecompiledRoute{}
fast_cache: FastRouteCache.new(cache_size * 10) // 高性能缓存
lru_cache: ContextLRUCache.new(cache_size)
regex_cache: map[string]FastCompiledRegex{}
use_fast_cache: true
}
}
@@ -171,17 +177,23 @@ fn (router FastRouter) precompile_route(method string, handler IHandler) !Precom
}
}
// 快速路由匹配(优化版:静态路由跳过缓存 + 预分配结果)
// 快速路由匹配(优化版:静态路由跳过缓存 + 预分配结果 + 高性能缓存)
pub fn (mut router FastRouter) match_route(method string, path string) ?ContextRouteMatch {
// 优化:只拼接一次 cache key,复用于静态路由和缓存查找
cache_key := '${method}:${path}'
// 1. 静态路由直接返回预分配的结果(零分配)
static_key := '${method}:${path}'
if result := router.static_route_results[static_key] {
if result := router.static_route_results[cache_key] {
return result
}
// 2. 动态路由先检查 LRU 缓存(避免重复正则匹配)
if router.cache_enabled {
cache_key := '${method}:${path}'
// 2. 动态路由先检查高性能缓存(零开销查找)
if router.cache_enabled && router.use_fast_cache {
if cached := router.fast_cache.get(cache_key) {
return cached
}
} else if router.cache_enabled {
// 回退到 LRU 缓存
if cached := router.lru_cache.get(cache_key) {
return cached
}
@@ -208,10 +220,13 @@ pub fn (mut router FastRouter) match_route(method string, path string) ?ContextR
base_path: ''
}
// 只缓存动态路由结果
// 缓存动态路由结果
if router.cache_enabled {
cache_key := '${method}:${path}'
router.lru_cache.put(cache_key, result)
if router.use_fast_cache {
router.fast_cache.put(cache_key, result)
} else {
router.lru_cache.put(cache_key, result)
}
}
return result
+565
View File
@@ -0,0 +1,565 @@
module hono
import os
import time
import crypto.md5
// FileService 统一文件服务层
// 提供高级文件操作 API,管理存储提供者和元数据
@[heap]
pub struct FileService {
mut:
provider &StorageProvider = unsafe { nil }
db DatabaseManager
config FileServiceConfig
chunk_manager ChunkManager
// 用于热加载的配置
current_config StorageConfig
}
// FileService 配置
pub struct FileServiceConfig {
pub:
storage StorageConfig
db_path string = './storage/files.db'
default_bucket string = 'default'
chunk_size int = 5 * 1024 * 1024 // 5MB
max_file_size i64 = 5 * 1024 * 1024 * 1024 // 5GB
}
// 文件上传参数
pub struct UploadParams {
pub:
bucket string
filename string
content_type string
metadata string // JSON 格式的额外元数据
}
// 文件上传结果
pub struct UploadResult {
pub:
file_uuid string
file_hash string
file_name string
file_size i64
file_type string
storage_type string
bucket string
object_key string
etag string
created_at i64
}
// 预签名 URL 结果
pub struct PresignResult {
pub:
url string
expires_at i64
method string
}
// ============================================================================
// 存储提供者工厂方法
// ============================================================================
// 根据配置创建存储提供者
pub fn create_storage_provider(config StorageConfig) !&StorageProvider {
match config.storage_type {
.local {
mut provider := new_local_storage(config.local)!
return &provider
}
.s3 {
mut provider := new_s3_storage(config.s3)!
return &provider
}
.aliyun_oss {
mut provider := new_aliyun_oss(config.aliyun_oss)!
return &provider
}
.tencent_cos {
mut provider := new_tencent_cos(config.tencent_cos)!
return &provider
}
}
}
// 根据存储类型字符串创建存储提供者
pub fn create_storage_provider_by_type(storage_type string, config StorageConfig) !&StorageProvider {
match storage_type.to_lower() {
'local' {
mut provider := new_local_storage(config.local)!
return &provider
}
's3' {
mut provider := new_s3_storage(config.s3)!
return &provider
}
'aliyun_oss', 'oss' {
mut provider := new_aliyun_oss(config.aliyun_oss)!
return &provider
}
'tencent_cos', 'cos' {
mut provider := new_tencent_cos(config.tencent_cos)!
return &provider
}
else {
return error('Unknown storage type: ${storage_type}')
}
}
}
// ============================================================================
// FileService 创建和初始化
// ============================================================================
// 创建 FileService
pub fn new_file_service(config FileServiceConfig) !FileService {
// 验证存储配置
validation := validate_storage_config(config.storage)
if !validation.valid {
return error(validation.error_message)
}
// 确保数据库目录存在
db_dir := os.dir(config.db_path)
if db_dir != '' && db_dir != '.' {
os.mkdir_all(db_dir) or {
return error('Failed to create database directory: ${err}')
}
}
// 创建数据库管理器
mut db := new_database_manager(config.db_path)!
// 创建存储提供者
provider := create_storage_provider(config.storage)!
// 创建分片管理器
chunk_manager := new_chunk_manager_default(mut db, provider)
return FileService{
provider: provider
db: db
config: config
chunk_manager: chunk_manager
current_config: config.storage
}
}
// 使用默认配置创建本地存储的 FileService
pub fn new_local_file_service(base_path string, db_path string) !FileService {
config := FileServiceConfig{
storage: new_local_storage_config(base_path)
db_path: db_path
default_bucket: 'default'
}
return new_file_service(config)
}
// 从 JSON 配置文件创建 FileService
pub fn new_file_service_from_config_file(config_path string, db_path string) !FileService {
storage_config := load_storage_config_from_file(config_path)!
config := FileServiceConfig{
storage: storage_config
db_path: db_path
default_bucket: get_default_bucket(storage_config)
}
return new_file_service(config)
}
// 从环境变量创建 FileService
pub fn new_file_service_from_env(db_path string) !FileService {
storage_config := load_storage_config_from_env()!
config := FileServiceConfig{
storage: storage_config
db_path: db_path
default_bucket: get_default_bucket(storage_config)
}
return new_file_service(config)
}
// 获取默认 bucket
fn get_default_bucket(config StorageConfig) string {
match config.storage_type {
.local { return 'default' }
.s3 { return config.s3.default_bucket }
.aliyun_oss { return config.aliyun_oss.default_bucket }
.tencent_cos { return config.tencent_cos.default_bucket }
}
}
// ============================================================================
// 高级文件操作 API
// ============================================================================
// 上传文件
pub fn (mut fs FileService) upload_file(params UploadParams, data []u8) !UploadResult {
bucket := if params.bucket != '' { params.bucket } else { fs.config.default_bucket }
// 检查文件大小
if i64(data.len) > fs.config.max_file_size {
return error('File size exceeds maximum allowed size: ${data.len} > ${fs.config.max_file_size}')
}
// 计算文件哈希
file_hash := calculate_md5_hash(data)
// 生成对象键
object_key := generate_object_key(params.filename, file_hash)
// 确定 content_type
content_type := if params.content_type != '' {
params.content_type
} else {
infer_content_type(params.filename)
}
// 上传到存储提供者
result := fs.provider.upload(bucket, object_key, data, content_type)!
// 保存文件元数据到数据库
file_info := fs.db.insert_file(FileInfo{
file_hash: file_hash
file_name: params.filename
file_size: i64(data.len)
file_type: content_type
storage_type: fs.provider.provider_name()
bucket: bucket
object_key: object_key
metadata: params.metadata
})!
return UploadResult{
file_uuid: file_info.file_uuid
file_hash: file_hash
file_name: params.filename
file_size: i64(data.len)
file_type: content_type
storage_type: fs.provider.provider_name()
bucket: bucket
object_key: object_key
etag: result.etag
created_at: file_info.created_at
}
}
// 下载文件(通过 UUID)
pub fn (mut fs FileService) download_file(file_uuid string) ![]u8 {
// 获取文件元数据
file_info := fs.db.get_file_by_uuid(file_uuid)!
// 从存储提供者下载
return fs.provider.download(file_info.bucket, file_info.object_key)
}
// 下载文件(通过 bucket 和 key)
pub fn (mut fs FileService) download_file_by_key(bucket string, object_key string) ![]u8 {
return fs.provider.download(bucket, object_key)
}
// 删除文件(通过 UUID)
pub fn (mut fs FileService) delete_file(file_uuid string) ! {
// 获取文件元数据
file_info := fs.db.get_file_by_uuid(file_uuid)!
// 从存储提供者删除
fs.provider.delete(file_info.bucket, file_info.object_key)!
// 从数据库删除元数据
fs.db.delete_file(file_uuid)!
}
// 获取文件信息(通过 UUID)
pub fn (fs FileService) get_file_info(file_uuid string) !FileInfo {
return fs.db.get_file_by_uuid(file_uuid)
}
// 获取文件信息(通过哈希)
pub fn (fs FileService) get_file_by_hash(file_hash string) !FileInfo {
return fs.db.get_file_by_hash(file_hash)
}
// 检查文件是否存在(通过 UUID)
pub fn (fs FileService) file_exists(file_uuid string) bool {
return fs.db.file_exists(file_uuid)
}
// 检查文件是否存在(通过 bucket 和 key)
pub fn (mut fs FileService) file_exists_by_key(bucket string, object_key string) !bool {
return fs.provider.exists(bucket, object_key)
}
// 获取预签名 URL
pub fn (mut fs FileService) get_presigned_url(file_uuid string, expires_in int) !PresignResult {
// 获取文件元数据
file_info := fs.db.get_file_by_uuid(file_uuid)!
// 生成预签名 URL
url := fs.provider.presign_url(file_info.bucket, file_info.object_key, PresignOptions{
expires_in: expires_in
method: 'GET'
})!
return PresignResult{
url: url
expires_at: time.now().unix() + i64(expires_in)
method: 'GET'
}
}
// 获取预签名上传 URL
pub fn (mut fs FileService) get_presigned_upload_url(bucket string, object_key string, expires_in int, content_type string) !PresignResult {
url := fs.provider.presign_url(bucket, object_key, PresignOptions{
expires_in: expires_in
method: 'PUT'
content_type: content_type
})!
return PresignResult{
url: url
expires_at: time.now().unix() + i64(expires_in)
method: 'PUT'
}
}
// 列出文件
pub fn (fs FileService) list_files(options FileListOptions) !FileListResult {
return fs.db.list_files(options)
}
// 更新文件元数据
pub fn (mut fs FileService) update_file_metadata(file_uuid string, file_name string, file_type string, metadata string) !FileInfo {
return fs.db.update_file(file_uuid, file_name, file_type, metadata)
}
// 复制文件
pub fn (mut fs FileService) copy_file(file_uuid string, dst_bucket string, dst_key string) !UploadResult {
// 获取源文件信息
src_info := fs.db.get_file_by_uuid(file_uuid)!
// 复制文件
result := fs.provider.copy(src_info.bucket, src_info.object_key, dst_bucket, dst_key)!
// 保存新文件元数据
new_info := fs.db.insert_file(FileInfo{
file_hash: src_info.file_hash
file_name: src_info.file_name
file_size: src_info.file_size
file_type: src_info.file_type
storage_type: fs.provider.provider_name()
bucket: dst_bucket
object_key: dst_key
metadata: src_info.metadata
})!
return UploadResult{
file_uuid: new_info.file_uuid
file_hash: src_info.file_hash
file_name: src_info.file_name
file_size: src_info.file_size
file_type: src_info.file_type
storage_type: fs.provider.provider_name()
bucket: dst_bucket
object_key: dst_key
etag: result.etag
created_at: new_info.created_at
}
}
// ============================================================================
// 分片上传操作
// ============================================================================
// 初始化分片上传
pub fn (mut fs FileService) init_multipart_upload(params InitMultipartParams) !MultipartUpload {
mut p := params
if p.bucket == '' {
p = InitMultipartParams{
...params
bucket: fs.config.default_bucket
}
}
if p.chunk_size == 0 {
p = InitMultipartParams{
...p
chunk_size: fs.config.chunk_size
}
}
return fs.chunk_manager.init_multipart(p)
}
// 上传分片
pub fn (mut fs FileService) upload_part(upload_id string, part_number int, data []u8) !ChunkUploadResult {
return fs.chunk_manager.upload_part(upload_id, part_number, data)
}
// 完成分片上传
pub fn (mut fs FileService) complete_multipart_upload(upload_id string) !StorageResult {
return fs.chunk_manager.complete_multipart(upload_id)
}
// 取消分片上传
pub fn (mut fs FileService) abort_multipart_upload(upload_id string) ! {
return fs.chunk_manager.abort_multipart(upload_id)
}
// 获取上传进度
pub fn (fs FileService) get_upload_progress(upload_id string) !UploadProgress {
return fs.chunk_manager.get_upload_progress(upload_id)
}
// 获取待上传的分片列表
pub fn (fs FileService) get_pending_parts(upload_id string) ![]int {
return fs.chunk_manager.get_pending_parts(upload_id)
}
// ============================================================================
// 提供者切换
// ============================================================================
// 切换存储提供者
pub fn (mut fs FileService) switch_provider(config StorageConfig) ! {
// 验证新配置
validation := validate_storage_config(config)
if !validation.valid {
return error(validation.error_message)
}
// 创建新的存储提供者
new_provider := create_storage_provider(config)!
// 更新提供者
fs.provider = new_provider
fs.current_config = config
// 更新分片管理器的提供者
fs.chunk_manager = new_chunk_manager_default(mut fs.db, new_provider)
}
// 切换到本地存储
pub fn (mut fs FileService) switch_to_local(base_path string) ! {
config := new_local_storage_config(base_path)
fs.switch_provider(config)!
}
// 切换到 S3 存储
pub fn (mut fs FileService) switch_to_s3(endpoint string, access_key string, secret_key string, bucket string) ! {
config := new_s3_storage_config(endpoint, access_key, secret_key, bucket)
fs.switch_provider(config)!
}
// 切换到 MinIO 存储
pub fn (mut fs FileService) switch_to_minio(endpoint string, access_key string, secret_key string, bucket string) ! {
config := new_minio_storage_config(endpoint, access_key, secret_key, bucket)
fs.switch_provider(config)!
}
// 切换到阿里云 OSS
pub fn (mut fs FileService) switch_to_aliyun_oss(endpoint string, access_key_id string, access_key_secret string, bucket string) ! {
config := new_aliyun_oss_storage_config(endpoint, access_key_id, access_key_secret, bucket)
fs.switch_provider(config)!
}
// 切换到腾讯云 COS
pub fn (mut fs FileService) switch_to_tencent_cos(secret_id string, secret_key string, region string, bucket string) ! {
config := new_tencent_cos_storage_config(secret_id, secret_key, region, bucket)
fs.switch_provider(config)!
}
// 从配置文件热加载配置
pub fn (mut fs FileService) reload_config_from_file(config_path string) ! {
config := load_storage_config_from_file(config_path)!
fs.switch_provider(config)!
}
// 从环境变量热加载配置
pub fn (mut fs FileService) reload_config_from_env() ! {
config := load_storage_config_from_env()!
fs.switch_provider(config)!
}
// ============================================================================
// 状态和信息查询
// ============================================================================
// 获取当前存储提供者名称
pub fn (mut fs FileService) get_current_provider_name() string {
return fs.provider.provider_name()
}
// 获取当前存储类型
pub fn (fs FileService) get_current_storage_type() StorageType {
return fs.current_config.storage_type
}
// 获取当前配置
pub fn (fs FileService) get_current_config() StorageConfig {
return fs.current_config
}
// 获取默认 bucket
pub fn (fs FileService) get_default_bucket() string {
return fs.config.default_bucket
}
// 获取分片大小
pub fn (fs FileService) get_chunk_size() int {
return fs.config.chunk_size
}
// 获取最大文件大小
pub fn (fs FileService) get_max_file_size() i64 {
return fs.config.max_file_size
}
// 检查 bucket 是否存在
pub fn (mut fs FileService) bucket_exists(bucket string) !bool {
return fs.provider.bucket_exists(bucket)
}
// 创建 bucket
pub fn (mut fs FileService) create_bucket(bucket string) ! {
return fs.provider.create_bucket(bucket)
}
// 删除 bucket
pub fn (mut fs FileService) delete_bucket(bucket string) ! {
return fs.provider.delete_bucket(bucket)
}
// 关闭 FileService
pub fn (mut fs FileService) close() {
fs.db.close()
}
// ============================================================================
// 辅助函数
// ============================================================================
// 计算 MD5 哈希
fn calculate_md5_hash(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return result
}
// 生成对象键
fn generate_object_key(filename string, file_hash string) string {
// 使用日期和哈希前缀组织文件
now := time.now()
date_prefix := now.custom_format('YYYY/MM/DD')
hash_prefix := file_hash[..8]
// 获取文件扩展名
ext := os.file_ext(filename)
return '${date_prefix}/${hash_prefix}/${file_hash}${ext}'
}
+778
View File
@@ -0,0 +1,778 @@
module hono
import net.http
import x.json2
import os
import time
// ============================================================================
// HTTP 请求/响应结构
// ============================================================================
// 文件上传请求(JSON 格式)
pub struct UploadRequest {
pub:
bucket string @[json: 'bucket']
filename string @[json: 'filename']
content_type string @[json: 'content_type']
metadata string @[json: 'metadata']
}
// 分片上传初始化请求
pub struct InitMultipartRequest {
pub:
bucket string @[json: 'bucket']
filename string @[json: 'filename']
file_size i64 @[json: 'file_size']
content_type string @[json: 'content_type']
chunk_size int @[json: 'chunk_size']
}
// 分片上传完成请求
pub struct CompleteMultipartRequest {
pub:
upload_id string @[json: 'upload_id']
}
// 分片上传取消请求
pub struct AbortMultipartRequest {
pub:
upload_id string @[json: 'upload_id']
}
// 预签名 URL 请求
pub struct PresignRequest {
pub:
file_uuid string @[json: 'file_uuid']
expires_in int @[json: 'expires_in']
method string @[json: 'method']
}
// 文件列表请求
pub struct ListFilesRequest {
pub:
bucket string @[json: 'bucket']
prefix string @[json: 'prefix']
storage_type string @[json: 'storage_type']
limit int @[json: 'limit']
offset int @[json: 'offset']
}
// 通用 API 响应
pub struct StorageApiResponse {
pub:
success bool @[json: 'success']
message string @[json: 'message']
data string @[json: 'data'] // JSON 编码的数据
}
// 存储错误响应
pub struct StorageErrorResponse {
pub:
success bool @[json: 'success']
error string @[json: 'error']
code int @[json: 'code']
}
// ============================================================================
// HTTP 处理器
// ============================================================================
// 处理文件上传(单文件)
// POST /upload
// Content-Type: multipart/form-data
// 表单字段: file (文件), bucket (可选), metadata (可选 JSON)
pub fn (mut fs FileService) handle_upload(mut ctx Context) http.Response {
// 解析 multipart 表单数据
content_type := ctx.req.header.get(.content_type) or { '' }
if !content_type.starts_with('multipart/form-data') {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Content-Type must be multipart/form-data'
code: 400
}))
}
// 使用 v-hono 的 multipart 解析器
parser := new_multipart_parser(content_type, ctx.body) or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to parse multipart data: ${err}'
code: 400
}))
}
items := parser.parse() or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to parse form data: ${err}'
code: 400
}))
}
// 提取表单字段
mut file_data := []u8{}
mut filename := ''
mut file_content_type := 'application/octet-stream'
mut bucket := ''
mut metadata := ''
for item in items {
match item.name {
'file' {
file_data = item.content.bytes()
filename = item.filename
if item.content_type != '' {
file_content_type = item.content_type
}
}
'bucket' {
bucket = item.content
}
'metadata' {
metadata = item.content
}
'content_type' {
file_content_type = item.content
}
else {}
}
}
if file_data.len == 0 || filename == '' {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file data or filename'
code: 400
}))
}
// 上传文件
result := fs.upload_file(UploadParams{
bucket: bucket
filename: filename
content_type: file_content_type
metadata: metadata
}, file_data) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Upload failed: ${err}'
code: 500
}))
}
ctx.status(201)
return ctx.json(json2.encode[UploadResult](result))
}
// 处理文件下载
// GET /download/:file_uuid
pub fn (mut fs FileService) handle_download(mut ctx Context) http.Response {
file_uuid := ctx.params['file_uuid'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file_uuid parameter'
code: 400
}))
}
// 获取文件信息
file_info := fs.get_file_info(file_uuid) or {
ctx.status(404)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'File not found: ${err}'
code: 404
}))
}
// 检查 Range 请求
range_header := ctx.req.header.get_custom('Range') or { '' }
if range_header != '' {
return fs.handle_range_download(mut ctx, file_info, range_header)
}
// 下载文件
data := fs.download_file(file_uuid) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Download failed: ${err}'
code: 500
}))
}
// 设置响应头
ctx.headers['Content-Type'] = file_info.file_type
ctx.headers['Content-Length'] = data.len.str()
ctx.headers['Content-Disposition'] = 'attachment; filename="${file_info.file_name}"'
ctx.headers['Accept-Ranges'] = 'bytes'
ctx.headers['ETag'] = '"${file_info.file_hash}"'
ctx.status(200)
mut headers := http.new_header()
for key, value in ctx.headers {
headers.add_custom(key, value) or { continue }
}
return http.Response{
status_code: 200
header: headers
body: data.bytestr()
}
}
// 处理 Range 请求下载
fn (mut fs FileService) handle_range_download(mut ctx Context, file_info FileInfo, range_header string) http.Response {
// 解析 Range 头
if !range_header.starts_with('bytes=') {
ctx.status(416)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid Range header'
code: 416
}))
}
range_spec := range_header[6..]
parts := range_spec.split('-')
if parts.len != 2 {
ctx.status(416)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid Range format'
code: 416
}))
}
file_size := file_info.file_size
mut start := i64(0)
mut end := file_size - 1
if parts[0] != '' {
start = parts[0].i64()
}
if parts[1] != '' {
end = parts[1].i64()
}
// 验证范围
if start < 0 || start >= file_size || end < start || end >= file_size {
ctx.status(416)
ctx.headers['Content-Range'] = 'bytes */${file_size}'
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Range not satisfiable'
code: 416
}))
}
// 下载完整文件
data := fs.download_file(file_info.file_uuid) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Download failed: ${err}'
code: 500
}))
}
// 提取范围数据
range_data := data[int(start)..int(end) + 1]
content_length := end - start + 1
// 设置响应头
ctx.headers['Content-Type'] = file_info.file_type
ctx.headers['Content-Length'] = content_length.str()
ctx.headers['Content-Range'] = 'bytes ${start}-${end}/${file_size}'
ctx.headers['Accept-Ranges'] = 'bytes'
ctx.headers['ETag'] = '"${file_info.file_hash}"'
ctx.status(206)
mut headers := http.new_header()
for key, value in ctx.headers {
headers.add_custom(key, value) or { continue }
}
return http.Response{
status_code: 206
header: headers
body: range_data.bytestr()
}
}
// 处理文件删除
// DELETE /files/:file_uuid
pub fn (mut fs FileService) handle_delete(mut ctx Context) http.Response {
file_uuid := ctx.params['file_uuid'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file_uuid parameter'
code: 400
}))
}
// 删除文件
fs.delete_file(file_uuid) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Delete failed: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[StorageApiResponse](StorageApiResponse{
success: true
message: 'File deleted successfully'
data: ''
}))
}
// ============================================================================
// 分片上传处理器
// ============================================================================
// 初始化分片上传
// POST /multipart/init
pub fn (mut fs FileService) handle_init_multipart(mut ctx Context) http.Response {
// 解析请求体
req := json2.decode[InitMultipartRequest](ctx.body) or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid request body: ${err}'
code: 400
}))
}
if req.filename == '' || req.file_size <= 0 {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing required fields: filename and file_size'
code: 400
}))
}
// 生成对象键
object_key := generate_multipart_object_key(req.filename)
// 初始化分片上传
upload := fs.init_multipart_upload(InitMultipartParams{
bucket: req.bucket
object_key: object_key
file_name: req.filename
file_size: req.file_size
content_type: req.content_type
chunk_size: req.chunk_size
}) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to init multipart upload: ${err}'
code: 500
}))
}
ctx.status(201)
return ctx.json(json2.encode[MultipartUpload](upload))
}
// 上传分片
// POST /multipart/upload/:upload_id/:part_number
pub fn (mut fs FileService) handle_upload_part(mut ctx Context) http.Response {
upload_id := ctx.params['upload_id'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing upload_id parameter'
code: 400
}))
}
part_number_str := ctx.params['part_number'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing part_number parameter'
code: 400
}))
}
part_number := part_number_str.int()
if part_number < 1 {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid part_number: must be >= 1'
code: 400
}))
}
// 获取分片数据
data := ctx.body.bytes()
if data.len == 0 {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Empty part data'
code: 400
}))
}
// 上传分片
result := fs.upload_part(upload_id, part_number, data) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to upload part: ${err}'
code: 500
}))
}
if !result.success {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: result.error_msg
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[ChunkUploadResult](result))
}
// 完成分片上传
// POST /multipart/complete
pub fn (mut fs FileService) handle_complete_multipart(mut ctx Context) http.Response {
// 解析请求体
req := json2.decode[CompleteMultipartRequest](ctx.body) or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid request body: ${err}'
code: 400
}))
}
if req.upload_id == '' {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing upload_id'
code: 400
}))
}
// 完成上传
result := fs.complete_multipart_upload(req.upload_id) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to complete multipart upload: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[StorageResult](result))
}
// 取消分片上传
// POST /multipart/abort
pub fn (mut fs FileService) handle_abort_multipart(mut ctx Context) http.Response {
// 解析请求体
req := json2.decode[AbortMultipartRequest](ctx.body) or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid request body: ${err}'
code: 400
}))
}
if req.upload_id == '' {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing upload_id'
code: 400
}))
}
// 取消上传
fs.abort_multipart_upload(req.upload_id) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to abort multipart upload: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[StorageApiResponse](StorageApiResponse{
success: true
message: 'Multipart upload aborted'
data: ''
}))
}
// 获取上传进度
// GET /multipart/progress/:upload_id
pub fn (fs FileService) handle_upload_progress(mut ctx Context) http.Response {
upload_id := ctx.params['upload_id'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing upload_id parameter'
code: 400
}))
}
// 获取进度
progress := fs.get_upload_progress(upload_id) or {
ctx.status(404)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Upload not found: ${err}'
code: 404
}))
}
ctx.status(200)
return ctx.json(json2.encode[UploadProgress](progress))
}
// ============================================================================
// 文件列表和元数据处理器
// ============================================================================
// 获取文件列表
// GET /files?bucket=xxx&prefix=xxx&limit=100&offset=0
pub fn (fs FileService) handle_list_files(mut ctx Context) http.Response {
bucket := ctx.query['bucket'] or { '' }
prefix := ctx.query['prefix'] or { '' }
storage_type := ctx.query['storage_type'] or { '' }
limit_str := ctx.query['limit'] or { '100' }
offset_str := ctx.query['offset'] or { '0' }
limit := limit_str.int()
offset := offset_str.int()
// 查询文件列表
result := fs.list_files(FileListOptions{
bucket: bucket
prefix: prefix
storage_type: storage_type
limit: if limit > 0 { limit } else { 100 }
offset: if offset >= 0 { offset } else { 0 }
}) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to list files: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[FileListResult](result))
}
// 获取文件信息
// GET /files/:file_uuid/info
pub fn (fs FileService) handle_get_file_info(mut ctx Context) http.Response {
file_uuid := ctx.params['file_uuid'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file_uuid parameter'
code: 400
}))
}
// 获取文件信息
file_info := fs.get_file_info(file_uuid) or {
ctx.status(404)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'File not found: ${err}'
code: 404
}))
}
ctx.status(200)
return ctx.json(json2.encode[FileInfo](file_info))
}
// 获取预签名 URL
// POST /presign
pub fn (mut fs FileService) handle_presign(mut ctx Context) http.Response {
// 解析请求体
req := json2.decode[PresignRequest](ctx.body) or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Invalid request body: ${err}'
code: 400
}))
}
if req.file_uuid == '' {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file_uuid'
code: 400
}))
}
expires_in := if req.expires_in > 0 { req.expires_in } else { 3600 }
// 生成预签名 URL
result := fs.get_presigned_url(req.file_uuid, expires_in) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to generate presigned URL: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[PresignResult](result))
}
// 获取预签名 URL (GET 方式)
// GET /presign/:file_uuid?expires_in=3600
pub fn (mut fs FileService) handle_presign_get(mut ctx Context) http.Response {
file_uuid := ctx.params['file_uuid'] or {
ctx.status(400)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Missing file_uuid parameter'
code: 400
}))
}
expires_in_str := ctx.query['expires_in'] or { '3600' }
mut expires_in := expires_in_str.int()
if expires_in <= 0 {
expires_in = 3600
}
// 生成预签名 URL
result := fs.get_presigned_url(file_uuid, expires_in) or {
ctx.status(500)
return ctx.json(json2.encode[StorageErrorResponse](StorageErrorResponse{
success: false
error: 'Failed to generate presigned URL: ${err}'
code: 500
}))
}
ctx.status(200)
return ctx.json(json2.encode[PresignResult](result))
}
// ============================================================================
// 辅助函数
// ============================================================================
// 生成分片上传的对象键
fn generate_multipart_object_key(filename string) string {
now := time.now()
date_prefix := now.custom_format('YYYY/MM/DD')
uuid := generate_file_uuid()
ext := os.file_ext(filename)
return '${date_prefix}/${uuid}${ext}'
}
// ============================================================================
// 路由注册
// ============================================================================
// 注册所有文件服务路由
// prefix: 路由前缀,例如 "/api/storage"
pub fn (mut fs FileService) register_routes(mut app Hono, prefix string) {
// 文件上传
app.post('${prefix}/upload', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_upload(mut ctx)
})
// 文件下载
app.get('${prefix}/download/:file_uuid', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_download(mut ctx)
})
// 文件删除
app.delete('${prefix}/files/:file_uuid', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_delete(mut ctx)
})
// 文件列表
app.get('${prefix}/files', fn [fs] (mut ctx Context) http.Response {
return fs.handle_list_files(mut ctx)
})
// 文件信息
app.get('${prefix}/files/:file_uuid/info', fn [fs] (mut ctx Context) http.Response {
return fs.handle_get_file_info(mut ctx)
})
// 预签名 URL (POST)
app.post('${prefix}/presign', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_presign(mut ctx)
})
// 预签名 URL (GET)
app.get('${prefix}/presign/:file_uuid', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_presign_get(mut ctx)
})
// 分片上传 - 初始化
app.post('${prefix}/multipart/init', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_init_multipart(mut ctx)
})
// 分片上传 - 上传分片
app.post('${prefix}/multipart/upload/:upload_id/:part_number', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_upload_part(mut ctx)
})
// 分片上传 - 完成
app.post('${prefix}/multipart/complete', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_complete_multipart(mut ctx)
})
// 分片上传 - 取消
app.post('${prefix}/multipart/abort', fn [mut fs] (mut ctx Context) http.Response {
return fs.handle_abort_multipart(mut ctx)
})
// 分片上传 - 进度查询
app.get('${prefix}/multipart/progress/:upload_id', fn [fs] (mut ctx Context) http.Response {
return fs.handle_upload_progress(mut ctx)
})
}
// 注册简化的路由(不带前缀)
pub fn (mut fs FileService) register_default_routes(mut app Hono) {
fs.register_routes(mut app, '/storage')
}
+600
View File
@@ -0,0 +1,600 @@
module hono
import crypto.sha256
import crypto.sha512
import encoding.base64
import net.http
import time
import x.json2
// JwtAlgorithm 枚举 - 支持的 JWT 签名算法
pub enum JwtAlgorithm {
hs256
hs384
hs512
}
// JwtVerifyOptions 结构体 - JWT 验证选项
pub struct JwtVerifyOptions {
pub:
iss string // 发行者验证
exp bool = true // 验证过期时间
nbf bool = true // 验证生效时间
iat bool = true // 验证签发时间
}
// JwtOptions 结构体 - JWT 中间件配置选项
pub struct JwtOptions {
pub:
secret string // 密钥(必需)
alg JwtAlgorithm = .hs256 // 算法
cookie string // 从 cookie 读取 token
header_name string = 'Authorization' // 请求头名称
verify_options JwtVerifyOptions = JwtVerifyOptions{}
}
// JwtPayload 结构体 - JWT 载荷
pub struct JwtPayload {
pub mut:
sub string // Subject
iss string // Issuer
aud string // Audience
exp i64 // Expiration Time (Unix timestamp)
nbf i64 // Not Before (Unix timestamp)
iat i64 // Issued At (Unix timestamp)
jti string // JWT ID
claims map[string]string // 自定义声明
}
// JwtHeader 结构体 - JWT 头部
struct JwtHeader {
alg string
typ string
}
// JwtToken 结构体 - 完整的 JWT Token
struct JwtToken {
header JwtHeader
payload JwtPayload
signature string
}
// base64url_encode - Base64URL 编码(不带填充)
fn base64url_encode(data []u8) string {
encoded := base64.encode(data)
// 转换为 URL 安全格式并移除填充
return encoded.replace('+', '-').replace('/', '_').trim_right('=')
}
// base64url_decode - Base64URL 解码
fn base64url_decode(data string) ![]u8 {
// 转换回标准 Base64 格式
mut standard := data.replace('-', '+').replace('_', '/')
// 添加填充
padding := (4 - standard.len % 4) % 4
for _ in 0 .. padding {
standard += '='
}
return base64.decode(standard)
}
// hmac_sha256 - HMAC-SHA256 签名
fn hmac_sha256(message []u8, secret []u8) []u8 {
block_size := 64 // SHA256 块大小
// 处理密钥
mut key := secret.clone()
if key.len > block_size {
// 如果密钥太长,先哈希
hash_result := sha256.sum(key)
key = []u8{len: 32}
for i := 0; i < 32; i++ {
key[i] = hash_result[i]
}
}
// 填充到块大小
for key.len < block_size {
key << u8(0)
}
// 计算 K' ⊕ ipad
mut i_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
i_key_pad[i] = key[i] ^ u8(0x36)
}
// 计算 K' ⊕ opad
mut o_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
o_key_pad[i] = key[i] ^ u8(0x5c)
}
// 计算内部哈希: H((K' ⊕ ipad) || m)
mut inner_data := i_key_pad.clone()
inner_data << message
inner_hash_result := sha256.sum(inner_data)
mut inner_hash := []u8{len: 32}
for i := 0; i < 32; i++ {
inner_hash[i] = inner_hash_result[i]
}
// 计算外部哈希: H((K' ⊕ opad) || inner_hash)
mut outer_data := o_key_pad.clone()
outer_data << inner_hash
outer_hash_result := sha256.sum(outer_data)
mut result := []u8{len: 32}
for i := 0; i < 32; i++ {
result[i] = outer_hash_result[i]
}
return result
}
// hmac_sha384 - HMAC-SHA384 签名
fn hmac_sha384(message []u8, secret []u8) []u8 {
block_size := 128 // SHA384 块大小
// 处理密钥
mut key := secret.clone()
if key.len > block_size {
// 如果密钥太长,先哈希
hash_result := sha512.sum384(key)
key = []u8{len: 48}
for i := 0; i < 48; i++ {
key[i] = hash_result[i]
}
}
// 填充到块大小
for key.len < block_size {
key << u8(0)
}
// 计算 K' ⊕ ipad
mut i_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
i_key_pad[i] = key[i] ^ u8(0x36)
}
// 计算 K' ⊕ opad
mut o_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
o_key_pad[i] = key[i] ^ u8(0x5c)
}
// 计算内部哈希
mut inner_data := i_key_pad.clone()
inner_data << message
inner_hash_result := sha512.sum384(inner_data)
mut inner_hash := []u8{len: 48}
for i := 0; i < 48; i++ {
inner_hash[i] = inner_hash_result[i]
}
// 计算外部哈希
mut outer_data := o_key_pad.clone()
outer_data << inner_hash
outer_hash_result := sha512.sum384(outer_data)
mut result := []u8{len: 48}
for i := 0; i < 48; i++ {
result[i] = outer_hash_result[i]
}
return result
}
// hmac_sha512 - HMAC-SHA512 签名
fn hmac_sha512(message []u8, secret []u8) []u8 {
block_size := 128 // SHA512 块大小
// 处理密钥
mut key := secret.clone()
if key.len > block_size {
// 如果密钥太长,先哈希
hash_result := sha512.sum512(key)
key = []u8{len: 64}
for i := 0; i < 64; i++ {
key[i] = hash_result[i]
}
}
// 填充到块大小
for key.len < block_size {
key << u8(0)
}
// 计算 K' ⊕ ipad
mut i_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
i_key_pad[i] = key[i] ^ u8(0x36)
}
// 计算 K' ⊕ opad
mut o_key_pad := []u8{len: block_size}
for i := 0; i < block_size; i++ {
o_key_pad[i] = key[i] ^ u8(0x5c)
}
// 计算内部哈希
mut inner_data := i_key_pad.clone()
inner_data << message
inner_hash_result := sha512.sum512(inner_data)
mut inner_hash := []u8{len: 64}
for i := 0; i < 64; i++ {
inner_hash[i] = inner_hash_result[i]
}
// 计算外部哈希
mut outer_data := o_key_pad.clone()
outer_data << inner_hash
outer_hash_result := sha512.sum512(outer_data)
mut result := []u8{len: 64}
for i := 0; i < 64; i++ {
result[i] = outer_hash_result[i]
}
return result
}
// sign_message - 根据算法签名消息
fn sign_message(message []u8, secret []u8, alg JwtAlgorithm) []u8 {
return match alg {
.hs256 { hmac_sha256(message, secret) }
.hs384 { hmac_sha384(message, secret) }
.hs512 { hmac_sha512(message, secret) }
}
}
// alg_to_string - 将算法枚举转换为字符串
fn alg_to_string(alg JwtAlgorithm) string {
return match alg {
.hs256 { 'HS256' }
.hs384 { 'HS384' }
.hs512 { 'HS512' }
}
}
// string_to_alg - 将字符串转换为算法枚举
fn string_to_alg(s string) !JwtAlgorithm {
return match s {
'HS256' { JwtAlgorithm.hs256 }
'HS384' { JwtAlgorithm.hs384 }
'HS512' { JwtAlgorithm.hs512 }
else { error('Unsupported algorithm: ${s}') }
}
}
// sign_jwt - 创建并签名 JWT token
// 返回格式: header.payload.signature
pub fn sign_jwt(payload JwtPayload, secret string, alg JwtAlgorithm) !string {
if secret.len == 0 {
return error('Secret is required')
}
// 构建 header
header := JwtHeader{
alg: alg_to_string(alg)
typ: 'JWT'
}
// 编码 header
header_json := '{"alg":"${header.alg}","typ":"${header.typ}"}'
header_encoded := base64url_encode(header_json.bytes())
// 编码 payload
payload_json := encode_payload(payload)
payload_encoded := base64url_encode(payload_json.bytes())
// 创建签名输入
signing_input := '${header_encoded}.${payload_encoded}'
// 签名
signature := sign_message(signing_input.bytes(), secret.bytes(), alg)
signature_encoded := base64url_encode(signature)
return '${signing_input}.${signature_encoded}'
}
// encode_payload - 将 JwtPayload 编码为 JSON 字符串
fn encode_payload(payload JwtPayload) string {
mut parts := []string{}
if payload.sub.len > 0 {
parts << '"sub":"${escape_json_string(payload.sub)}"'
}
if payload.iss.len > 0 {
parts << '"iss":"${escape_json_string(payload.iss)}"'
}
if payload.aud.len > 0 {
parts << '"aud":"${escape_json_string(payload.aud)}"'
}
if payload.exp != 0 {
parts << '"exp":${payload.exp}'
}
if payload.nbf != 0 {
parts << '"nbf":${payload.nbf}'
}
if payload.iat != 0 {
parts << '"iat":${payload.iat}'
}
if payload.jti.len > 0 {
parts << '"jti":"${escape_json_string(payload.jti)}"'
}
// 添加自定义声明
for key, value in payload.claims {
parts << '"${escape_json_string(key)}":"${escape_json_string(value)}"'
}
return '{${parts.join(",")}}'
}
// escape_json_string - 转义 JSON 字符串中的特殊字符
fn escape_json_string(s string) string {
mut result := []u8{}
for c in s.bytes() {
match c {
`"` { result << `\\`; result << `"` }
`\\` { result << `\\`; result << `\\` }
`\n` { result << `\\`; result << `n` }
`\r` { result << `\\`; result << `r` }
`\t` { result << `\\`; result << `t` }
else { result << c }
}
}
return result.bytestr()
}
// decode_jwt - 解码 JWT token(不验证签名)
// 返回解码后的 payload
pub fn decode_jwt(token string) !JwtPayload {
parts := token.split('.')
if parts.len != 3 {
return error('Invalid token format')
}
// 解码 payload
payload_bytes := base64url_decode(parts[1])!
payload_json := payload_bytes.bytestr()
return parse_payload(payload_json)
}
// parse_payload - 解析 JSON payload 为 JwtPayload 结构体
fn parse_payload(json_str string) !JwtPayload {
raw := json2.decode[json2.Any](json_str) or {
return error('Invalid JSON payload: ${err}')
}
obj := raw.as_map()
mut payload := JwtPayload{
claims: map[string]string{}
}
// 解析标准声明
if 'sub' in obj {
payload.sub = obj['sub'] or { json2.Any('') }.str()
}
if 'iss' in obj {
payload.iss = obj['iss'] or { json2.Any('') }.str()
}
if 'aud' in obj {
payload.aud = obj['aud'] or { json2.Any('') }.str()
}
if 'exp' in obj {
payload.exp = (obj['exp'] or { json2.Any(0) }).i64()
}
if 'nbf' in obj {
payload.nbf = (obj['nbf'] or { json2.Any(0) }).i64()
}
if 'iat' in obj {
payload.iat = (obj['iat'] or { json2.Any(0) }).i64()
}
if 'jti' in obj {
payload.jti = obj['jti'] or { json2.Any('') }.str()
}
// 解析自定义声明
standard_claims := ['sub', 'iss', 'aud', 'exp', 'nbf', 'iat', 'jti']
for key, value in obj {
if key !in standard_claims {
payload.claims[key] = value.str()
}
}
return payload
}
// verify_jwt - 验证 JWT token 并返回 payload
// 验证签名、过期时间、生效时间等
pub fn verify_jwt(token string, secret string, alg JwtAlgorithm) !JwtPayload {
if secret.len == 0 {
return error('Secret is required')
}
parts := token.split('.')
if parts.len != 3 {
return error('Invalid token format')
}
// 解码 header 并验证算法
header_bytes := base64url_decode(parts[0])!
header_json := header_bytes.bytestr()
header_raw := json2.decode[json2.Any](header_json) or {
return error('Invalid header JSON: ${err}')
}
header_obj := header_raw.as_map()
token_alg_str := (header_obj['alg'] or { json2.Any('') }).str()
token_alg := string_to_alg(token_alg_str)!
// 验证算法一致性
if token_alg != alg {
return error('Algorithm mismatch: expected ${alg_to_string(alg)}, got ${token_alg_str}')
}
// 验证签名
signing_input := '${parts[0]}.${parts[1]}'
expected_signature := sign_message(signing_input.bytes(), secret.bytes(), alg)
expected_signature_encoded := base64url_encode(expected_signature)
if !constant_time_compare_jwt(parts[2], expected_signature_encoded) {
return error('Invalid signature')
}
// 解码 payload
payload := decode_jwt(token)!
return payload
}
// verify_jwt_with_options - 带选项的 JWT 验证
// 验证签名、过期时间、生效时间、发行者等
pub fn verify_jwt_with_options(token string, secret string, alg JwtAlgorithm, options JwtVerifyOptions) !JwtPayload {
// 先进行基本验证
payload := verify_jwt(token, secret, alg)!
now := time.now().unix()
// 验证过期时间
if options.exp && payload.exp != 0 {
if now > payload.exp {
return error('Token expired')
}
}
// 验证生效时间
if options.nbf && payload.nbf != 0 {
if now < payload.nbf {
return error('Token not yet valid')
}
}
// 验证发行者
if options.iss.len > 0 {
if payload.iss != options.iss {
return error('Invalid issuer: expected ${options.iss}, got ${payload.iss}')
}
}
return payload
}
// constant_time_compare_jwt - 常量时间比较,防止时序攻击
fn constant_time_compare_jwt(a string, b string) bool {
if a.len != b.len {
return false
}
mut result := u8(0)
for i := 0; i < a.len; i++ {
result |= a[i] ^ b[i]
}
return result == 0
}
// jwt - JWT 中间件工厂函数
// 返回一个 ContextMiddleware,用于验证 JWT token
pub fn jwt_middleware(options JwtOptions) ContextMiddleware {
return fn [options] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 获取 token
token := get_jwt_token(c, options) or {
c.status(401)
return c.json('{"error":"Unauthorized","message":"${err}"}')
}
// 验证 token
payload := verify_jwt_with_options(token, options.secret, options.alg, options.verify_options) or {
c.status(401)
return c.json('{"error":"Unauthorized","message":"${err}"}')
}
// 将 payload 存储到 Context
store_jwt_payload(mut c, payload)
// 继续处理请求
return next(mut c)
}
}
// get_jwt_token - 从请求中获取 JWT token
fn get_jwt_token(c Context, options JwtOptions) !string {
// 优先从 Cookie 读取
if options.cookie.len > 0 {
if cookie_token := get_cookie(c, options.cookie) {
return cookie_token
}
}
// 从 header 读取
header_value := c.req.header.get_custom(options.header_name) or {
return error('Missing authorization header')
}
if header_value.len == 0 {
return error('Missing authorization header')
}
// 解析 Bearer token
if header_value.starts_with('Bearer ') {
token := header_value[7..].trim_space()
if token.len == 0 {
return error('Invalid token format')
}
return token
}
// 如果不是 Bearer 格式,直接返回整个值
return header_value
}
// store_jwt_payload - 将 JWT payload 存储到 Context
fn store_jwt_payload(mut c Context, payload JwtPayload) {
// 存储标准声明
if payload.sub.len > 0 {
c.set('jwt_sub', payload.sub)
}
if payload.iss.len > 0 {
c.set('jwt_iss', payload.iss)
}
if payload.aud.len > 0 {
c.set('jwt_aud', payload.aud)
}
if payload.exp != 0 {
c.set('jwt_exp', payload.exp.str())
}
if payload.nbf != 0 {
c.set('jwt_nbf', payload.nbf.str())
}
if payload.iat != 0 {
c.set('jwt_iat', payload.iat.str())
}
if payload.jti.len > 0 {
c.set('jwt_jti', payload.jti)
}
// 存储自定义声明
for key, value in payload.claims {
c.set('jwt_${key}', value)
}
// 存储完整的 payload JSON(便于获取)
c.set('jwt_payload', encode_payload(payload))
}
// get_jwt_payload - 从 Context 获取 JWT payload
// 这是一个便捷方法,用于在 handler 中获取已验证的 JWT payload
pub fn get_jwt_payload(c Context) ?JwtPayload {
payload_json := c.get('jwt_payload') or { return none }
payload := parse_payload(payload_json) or { return none }
return payload
}
// get_jwt_claim - 从 Context 获取单个 JWT 声明
pub fn get_jwt_claim(c Context, key string) ?string {
return c.get('jwt_${key}')
}
+611
View File
@@ -0,0 +1,611 @@
module hono
import os
import io
import time
import rand
import crypto.md5
// LocalStorage 本地存储提供者
pub struct LocalStorage {
config LocalStorageConfig
mut:
// 用于跟踪分片上传的内存存储
multipart_uploads map[string]MultipartUploadState
}
// 分片上传状态
struct MultipartUploadState {
mut:
bucket string
key string
content_type string
parts map[int]PartInfo
created_at i64
}
// 创建本地存储提供者
pub fn new_local_storage(config LocalStorageConfig) !LocalStorage {
// 确保基础目录存在
if config.create_dirs {
os.mkdir_all(config.base_path) or {
return error('Failed to create base directory: ${err}')
}
}
return LocalStorage{
config: config
multipart_uploads: map[string]MultipartUploadState{}
}
}
// 获取文件的完整路径
fn (s LocalStorage) get_full_path(bucket string, key string) string {
return os.join_path(s.config.base_path, bucket, key)
}
// 获取 bucket 目录路径
fn (s LocalStorage) get_bucket_path(bucket string) string {
return os.join_path(s.config.base_path, bucket)
}
// 计算数据的 ETag (MD5)
fn calculate_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
// ============================================================================
// StorageProvider 接口实现 - 基本操作
// ============================================================================
// 上传文件
pub fn (mut s LocalStorage) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
// 确保 bucket 目录存在
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error(new_storage_error(.invalid_config, 'Failed to create bucket directory: ${err}',
'local', 'upload').msg())
}
}
// 获取完整文件路径
full_path := s.get_full_path(bucket, key)
// 确保父目录存在
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error(new_storage_error(.invalid_config, 'Failed to create parent directory: ${err}',
'local', 'upload').msg())
}
}
// 写入文件
os.write_file_array(full_path, data) or {
return error(new_storage_error(.unknown, 'Failed to write file: ${err}', 'local',
'upload').msg())
}
// 计算 ETag
etag := calculate_etag(data)
return new_storage_result(key, etag, i64(data.len))
}
// 流式上传文件
pub fn (mut s LocalStorage) upload_stream(bucket string, key string, mut reader io.Reader, size i64, content_type string) !StorageResult {
// 确保 bucket 目录存在
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error(new_storage_error(.invalid_config, 'Failed to create bucket directory: ${err}',
'local', 'upload_stream').msg())
}
}
// 获取完整文件路径
full_path := s.get_full_path(bucket, key)
// 确保父目录存在
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error(new_storage_error(.invalid_config, 'Failed to create parent directory: ${err}',
'local', 'upload_stream').msg())
}
}
// 读取所有数据
mut data := []u8{}
mut buf := []u8{len: 8192}
for {
n := reader.read(mut buf) or { break }
if n == 0 {
break
}
data << buf[..n]
}
// 写入文件
os.write_file_array(full_path, data) or {
return error(new_storage_error(.unknown, 'Failed to write file: ${err}', 'local',
'upload_stream').msg())
}
// 计算 ETag
etag := calculate_etag(data)
return new_storage_result(key, etag, i64(data.len))
}
// 下载文件
pub fn (s LocalStorage) download(bucket string, key string) ![]u8 {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error(new_not_found_error('local', bucket, key).msg())
}
data := os.read_bytes(full_path) or {
return error(new_storage_error(.unknown, 'Failed to read file: ${err}', 'local',
'download').msg())
}
return data
}
// 流式下载文件
pub fn (s LocalStorage) download_stream(bucket string, key string, mut writer io.Writer) !i64 {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error(new_not_found_error('local', bucket, key).msg())
}
data := os.read_bytes(full_path) or {
return error(new_storage_error(.unknown, 'Failed to read file: ${err}', 'local',
'download_stream').msg())
}
written := writer.write(data) or {
return error(new_storage_error(.unknown, 'Failed to write to stream: ${err}', 'local',
'download_stream').msg())
}
return i64(written)
}
// 删除文件
pub fn (s LocalStorage) delete(bucket string, key string) ! {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error(new_not_found_error('local', bucket, key).msg())
}
os.rm(full_path) or {
return error(new_storage_error(.unknown, 'Failed to delete file: ${err}', 'local',
'delete').msg())
}
}
// 检查文件是否存在
pub fn (s LocalStorage) exists(bucket string, key string) !bool {
full_path := s.get_full_path(bucket, key)
return os.exists(full_path) && os.is_file(full_path)
}
// ============================================================================
// StorageProvider 接口实现 - 元数据操作
// ============================================================================
// 获取文件元数据
pub fn (s LocalStorage) head(bucket string, key string) !ObjectInfo {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error(new_not_found_error('local', bucket, key).msg())
}
// 获取文件信息
file_size := os.file_size(full_path)
mtime := os.file_last_mod_unix(full_path)
// 读取文件计算 ETag
data := os.read_bytes(full_path) or {
return error(new_storage_error(.unknown, 'Failed to read file for metadata: ${err}',
'local', 'head').msg())
}
etag := calculate_etag(data)
// 推断 content_type
content_type := infer_content_type(key)
return new_object_info(key, i64(file_size), etag, content_type, mtime)
}
// 复制文件
pub fn (mut s LocalStorage) copy(src_bucket string, src_key string, dst_bucket string, dst_key string) !StorageResult {
src_path := s.get_full_path(src_bucket, src_key)
if !os.exists(src_path) {
return error(new_not_found_error('local', src_bucket, src_key).msg())
}
// 确保目标 bucket 目录存在
dst_bucket_path := s.get_bucket_path(dst_bucket)
if s.config.create_dirs {
os.mkdir_all(dst_bucket_path) or {
return error(new_storage_error(.invalid_config, 'Failed to create destination bucket directory: ${err}',
'local', 'copy').msg())
}
}
dst_path := s.get_full_path(dst_bucket, dst_key)
// 确保目标父目录存在
parent_dir := os.dir(dst_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error(new_storage_error(.invalid_config, 'Failed to create destination parent directory: ${err}',
'local', 'copy').msg())
}
}
// 复制文件
os.cp(src_path, dst_path) or {
return error(new_storage_error(.unknown, 'Failed to copy file: ${err}', 'local',
'copy').msg())
}
// 读取文件计算 ETag 和大小
data := os.read_bytes(dst_path) or {
return error(new_storage_error(.unknown, 'Failed to read copied file: ${err}', 'local',
'copy').msg())
}
etag := calculate_etag(data)
return new_storage_result(dst_key, etag, i64(data.len))
}
// ============================================================================
// StorageProvider 接口实现 - 列表操作
// ============================================================================
// 列出文件
pub fn (s LocalStorage) list(bucket string, options ListOptions) !ListResult {
bucket_path := s.get_bucket_path(bucket)
if !os.exists(bucket_path) {
return error(new_bucket_not_found_error('local', bucket).msg())
}
mut objects := []ObjectInfo{}
mut common_prefixes := []string{}
// 递归遍历目录
s.list_directory_recursive(bucket_path, bucket_path, options.prefix, options.delimiter,
options.start_after, options.max_keys, mut objects, mut common_prefixes)
// 检查是否有更多结果
is_truncated := objects.len >= options.max_keys
next_marker := if is_truncated && objects.len > 0 { objects.last().key } else { '' }
return new_list_result(objects, common_prefixes, is_truncated, next_marker)
}
// 递归列出目录内容
fn (s LocalStorage) list_directory_recursive(base_path string, current_path string, prefix string, delimiter string, start_after string, max_keys int, mut objects []ObjectInfo, mut common_prefixes []string) {
if objects.len >= max_keys {
return
}
entries := os.ls(current_path) or { return }
for entry in entries {
if objects.len >= max_keys {
return
}
entry_path := os.join_path(current_path, entry)
// 计算相对于 bucket 的 key
relative_key := entry_path.replace(base_path + os.path_separator, '').replace(os.path_separator,
'/')
// 检查前缀匹配
if prefix != '' && !relative_key.starts_with(prefix) {
continue
}
// 检查 start_after
if start_after != '' && relative_key <= start_after {
continue
}
if os.is_dir(entry_path) {
// 如果有分隔符,添加到 common_prefixes
if delimiter != '' {
prefix_key := relative_key + '/'
if prefix_key !in common_prefixes {
common_prefixes << prefix_key
}
} else {
// 递归遍历子目录
s.list_directory_recursive(base_path, entry_path, prefix, delimiter,
start_after, max_keys, mut objects, mut common_prefixes)
}
} else {
// 文件
file_size := os.file_size(entry_path)
mtime := os.file_last_mod_unix(entry_path)
// 读取文件计算 ETag
data := os.read_bytes(entry_path) or { continue }
etag := calculate_etag(data)
content_type := infer_content_type(relative_key)
objects << new_object_info(relative_key, i64(file_size), etag, content_type,
mtime)
}
}
}
// ============================================================================
// StorageProvider 接口实现 - 预签名 URL
// ============================================================================
// 生成预签名 URL
pub fn (s LocalStorage) presign_url(bucket string, key string, options PresignOptions) !string {
full_path := s.get_full_path(bucket, key)
// 对于 GET 请求,检查文件是否存在
if options.method == 'GET' && !os.exists(full_path) {
return error(new_not_found_error('local', bucket, key).msg())
}
// 本地存储返回 HTTP URL 或文件路径
if s.config.url_prefix != '' {
// 返回 HTTP URL
return '${s.config.url_prefix}/${bucket}/${key}'
} else {
// 返回本地文件路径
return full_path
}
}
// ============================================================================
// StorageProvider 接口实现 - 分片上传
// ============================================================================
// 初始化分片上传
pub fn (mut s LocalStorage) init_multipart(bucket string, key string, content_type string) !string {
// 确保 bucket 目录存在
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error(new_storage_error(.invalid_config, 'Failed to create bucket directory: ${err}',
'local', 'init_multipart').msg())
}
}
// 生成 upload_id
upload_id := generate_upload_id()
// 创建临时目录存储分片
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
os.mkdir_all(temp_dir) or {
return error(new_storage_error(.unknown, 'Failed to create temp directory: ${err}',
'local', 'init_multipart').msg())
}
// 记录上传状态
s.multipart_uploads[upload_id] = MultipartUploadState{
bucket: bucket
key: key
content_type: content_type
parts: map[int]PartInfo{}
created_at: time.now().unix()
}
return upload_id
}
// 上传分片
pub fn (mut s LocalStorage) upload_part(bucket string, key string, upload_id string, part_number int, data []u8) !string {
// 检查上传是否存在
if upload_id !in s.multipart_uploads {
return error(new_storage_error(.object_not_found, 'Multipart upload not found: ${upload_id}',
'local', 'upload_part').msg())
}
// 写入分片文件
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
part_path := os.join_path(temp_dir, '${part_number}')
os.write_file_array(part_path, data) or {
return error(new_storage_error(.unknown, 'Failed to write part: ${err}', 'local',
'upload_part').msg())
}
// 计算 ETag
etag := calculate_etag(data)
// 更新上传状态
mut upload_state := s.multipart_uploads[upload_id]
upload_state.parts[part_number] = new_part_info(part_number, etag, i64(data.len))
s.multipart_uploads[upload_id] = upload_state
return etag
}
// 完成分片上传
pub fn (mut s LocalStorage) complete_multipart(bucket string, key string, upload_id string, parts []PartInfo) !StorageResult {
// 检查上传是否存在
if upload_id !in s.multipart_uploads {
return error(new_storage_error(.object_not_found, 'Multipart upload not found: ${upload_id}',
'local', 'complete_multipart').msg())
}
_ := s.multipart_uploads[upload_id] // 验证上传存在
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
// 合并所有分片
mut final_data := []u8{}
for part in parts {
part_path := os.join_path(temp_dir, '${part.part_number}')
part_data := os.read_bytes(part_path) or {
return error(new_storage_error(.object_not_found, 'Part not found: ${part.part_number}',
'local', 'complete_multipart').msg())
}
final_data << part_data
}
// 写入最终文件
full_path := s.get_full_path(bucket, key)
// 确保父目录存在
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error(new_storage_error(.invalid_config, 'Failed to create parent directory: ${err}',
'local', 'complete_multipart').msg())
}
}
os.write_file_array(full_path, final_data) or {
return error(new_storage_error(.unknown, 'Failed to write final file: ${err}',
'local', 'complete_multipart').msg())
}
// 清理临时文件
os.rmdir_all(temp_dir) or {}
// 删除上传状态
s.multipart_uploads.delete(upload_id)
// 计算 ETag
etag := calculate_etag(final_data)
return new_storage_result(key, etag, i64(final_data.len))
}
// 取消分片上传
pub fn (mut s LocalStorage) abort_multipart(bucket string, key string, upload_id string) ! {
// 检查上传是否存在
if upload_id !in s.multipart_uploads {
return error(new_storage_error(.object_not_found, 'Multipart upload not found: ${upload_id}',
'local', 'abort_multipart').msg())
}
// 清理临时文件
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
os.rmdir_all(temp_dir) or {}
// 删除上传状态
s.multipart_uploads.delete(upload_id)
}
// ============================================================================
// StorageProvider 接口实现 - Bucket 操作
// ============================================================================
// 创建 bucket
pub fn (s LocalStorage) create_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if os.exists(bucket_path) {
return // Bucket 已存在,不报错
}
os.mkdir_all(bucket_path) or {
return error(new_storage_error(.unknown, 'Failed to create bucket: ${err}', 'local',
'create_bucket').msg())
}
}
// 删除 bucket
pub fn (s LocalStorage) delete_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if !os.exists(bucket_path) {
return error(new_bucket_not_found_error('local', bucket).msg())
}
// 检查 bucket 是否为空
entries := os.ls(bucket_path) or { []string{} }
if entries.len > 0 {
return error(new_storage_error(.access_denied, 'Bucket is not empty', 'local',
'delete_bucket').msg())
}
os.rmdir(bucket_path) or {
return error(new_storage_error(.unknown, 'Failed to delete bucket: ${err}', 'local',
'delete_bucket').msg())
}
}
// 检查 bucket 是否存在
pub fn (s LocalStorage) bucket_exists(bucket string) !bool {
bucket_path := s.get_bucket_path(bucket)
return os.exists(bucket_path) && os.is_dir(bucket_path)
}
// 获取提供者名称
pub fn (s LocalStorage) provider_name() string {
return 'local'
}
// ============================================================================
// 辅助函数
// ============================================================================
// 生成上传 ID
fn generate_upload_id() string {
now := time.now().unix_nano()
random_bytes := rand.bytes(8) or { []u8{len: 8} }
mut random_hex := ''
for b in random_bytes {
random_hex += '${b:02x}'
}
return '${now}-${random_hex}'
}
// 根据文件扩展名推断 content_type
fn infer_content_type(key string) string {
ext := os.file_ext(key).to_lower()
match ext {
'.html', '.htm' { return 'text/html' }
'.css' { return 'text/css' }
'.js' { return 'application/javascript' }
'.json' { return 'application/json' }
'.xml' { return 'application/xml' }
'.txt' { return 'text/plain' }
'.png' { return 'image/png' }
'.jpg', '.jpeg' { return 'image/jpeg' }
'.gif' { return 'image/gif' }
'.svg' { return 'image/svg+xml' }
'.webp' { return 'image/webp' }
'.ico' { return 'image/x-icon' }
'.pdf' { return 'application/pdf' }
'.zip' { return 'application/zip' }
'.gz', '.gzip' { return 'application/gzip' }
'.tar' { return 'application/x-tar' }
'.mp3' { return 'audio/mpeg' }
'.mp4' { return 'video/mp4' }
'.webm' { return 'video/webm' }
'.woff' { return 'font/woff' }
'.woff2' { return 'font/woff2' }
'.ttf' { return 'font/ttf' }
'.otf' { return 'font/otf' }
else { return 'application/octet-stream' }
}
}
+485
View File
@@ -0,0 +1,485 @@
// middleware.v - 中间件统一导出模块
// 本模块提供所有内置中间件的统一访问接口
// 使用方式: import hono 后可通过 hono.cors(), hono.jwt_middleware() 等方式调用
module hono
import net.http
import time
import rand
// ============================================================================
// 中间件导出说明
// ============================================================================
//
// 本框架提供以下 9 个内置中间件:
//
// 1. CORS 中间件 (cors.v)
// - cors(options ...CorsOptions) ContextMiddleware
// - 用于处理跨域资源共享
//
// 2. Cookie Helper (cookie.v)
// - get_cookie(c Context, name string) ?string
// - get_all_cookies(c Context) map[string]string
// - set_cookie(mut c Context, name string, value string, options ...CookieOptions)
// - delete_cookie(mut c Context, name string, options ...CookieOptions)
// - set_signed_cookie(mut c Context, name string, value string, secret string, options ...CookieOptions) !
// - get_signed_cookie(c Context, name string, secret string) !string
//
// 3. JWT 中间件 (jwt.v)
// - jwt_middleware(options JwtOptions) ContextMiddleware
// - sign_jwt(payload JwtPayload, secret string, alg JwtAlgorithm) !string
// - verify_jwt(token string, secret string, alg JwtAlgorithm) !JwtPayload
// - decode_jwt(token string) !JwtPayload
// - get_jwt_payload(c Context) ?JwtPayload
// - get_jwt_claim(c Context, key string) ?string
//
// 4. Bearer Auth 中间件 (bearer_auth.v)
// - bearer_auth(options BearerAuthOptions) ContextMiddleware
// - get_bearer_token(c Context) ?string
//
// 5. 压缩中间件 (compress.v)
// - compress(options ...CompressOptions) ContextMiddleware
// - decompress_gzip(data []u8) ![]u8
// - decompress_deflate(data []u8) ![]u8
//
// 6. 限流中间件 (rate_limit.v)
// - rate_limit(options RateLimitOptions) ContextMiddleware
// - MemoryStore.new() &MemoryStore
// - get_rate_limit_info(c Context) ?RateLimitInfo
//
// 7. 请求验证系统 (validator.v)
// - validator(target ValidationTarget, schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware
// - v_string() StringSchema
// - v_int() IntSchema
// - v_float() FloatSchema
// - v_bool() BoolSchema
// - v_array(items Schema) ArraySchema
// - v_object(properties map[string]Schema) ObjectSchema
// - get_validated_data(c Context) map[string]string
// - get_validated_json(c Context) ?string
// - get_validated_field(c Context, field string) ?string
//
// 8. WebSocket Helper (websocket.v)
// - upgrade_websocket(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response
// - 用于处理 WebSocket 连接升级和事件处理
//
// Types:
// - WebSocketOptions: 配置选项 (ping_interval, max_message_size, timeout, protocols)
// - WSReadyState: 连接状态枚举 (connecting, open, closing, closed)
// - WSMessageEvent: 消息事件结构
// - WSCloseEvent: 关闭事件结构
// - WSContext: WebSocket 上下文,提供 send/send_bytes/send_json/close 方法
// - WSEvents: 事件处理器配置 (on_open, on_message, on_close, on_error)
// - WSHandlerFactory: 处理器工厂函数类型
//
// Constants:
// - ws_opcode_text, ws_opcode_binary, ws_opcode_close, ws_opcode_ping, ws_opcode_pong
// - ws_close_normal, ws_close_going_away, ws_close_protocol_error, etc.
//
// Helper Functions:
// - is_websocket_upgrade(c Context) bool: 检查是否为 WebSocket 升级请求
// - compute_accept_key(key string) string: 计算 Sec-WebSocket-Accept
// - 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' }))
//
// ============================================================================
// ============================================================================
// 中间件快捷别名函数
// 提供更简洁的调用方式,与 Hono.js 风格保持一致
// ============================================================================
// cors_middleware - CORS 中间件的别名函数
// 使用示例:
// app.use(hono.cors_middleware())
// app.use(hono.cors_middleware(CorsOptions{ origin: 'https://example.com' }))
pub fn cors_middleware(options ...CorsOptions) ContextMiddleware {
return cors(...options)
}
// jwt_auth - JWT 认证中间件的别名函数
// 使用示例:
// app.use('/api/*', hono.jwt_auth(JwtOptions{ secret: 'my-secret' }))
pub fn jwt_auth(options JwtOptions) ContextMiddleware {
return jwt_middleware(options)
}
// bearer - Bearer Token 认证中间件的别名函数
// 使用示例:
// app.use('/api/*', hono.bearer(BearerAuthOptions{ token: 'my-token' }))
pub fn bearer(options BearerAuthOptions) ContextMiddleware {
return bearer_auth(options)
}
// gzip - 压缩中间件的别名函数(默认使用 gzip)
// 使用示例:
// app.use(hono.gzip())
pub fn gzip(options ...CompressOptions) ContextMiddleware {
if options.len > 0 {
return compress(options[0])
}
return compress(CompressOptions{
encoding: .gzip
})
}
// deflate_compress - 压缩中间件的别名函数(使用 deflate)
// 使用示例:
// app.use(hono.deflate_compress())
pub fn deflate_compress(options ...CompressOptions) ContextMiddleware {
if options.len > 0 {
return compress(options[0])
}
return compress(CompressOptions{
encoding: .deflate
})
}
// rate_limiter - 限流中间件的别名函数
// 使用示例:
// store := MemoryStore.new()
// app.use(hono.rate_limiter(RateLimitOptions{ store: store, limit: 100 }))
pub fn rate_limiter(options RateLimitOptions) ContextMiddleware {
return rate_limit(options)
}
// validate_json - JSON body 验证中间件的便捷函数
// 使用示例:
// app.post('/users', hono.validate_json(v_object({
// 'name': v_string().required()
// 'email': v_string().required()
// })), handler)
pub fn validate_json(schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware {
return validator(.json, schema, ...options)
}
// validate_query - Query 参数验证中间件的便捷函数
// 使用示例:
// app.get('/search', hono.validate_query(v_object({
// 'q': v_string().required()
// 'page': v_int().min(1)
// })), handler)
pub fn validate_query(schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware {
return validator(.query, schema, ...options)
}
// validate_params - Path 参数验证中间件的便捷函数
// 使用示例:
// app.get('/users/:id', hono.validate_params(v_object({
// 'id': v_int().required().min(1)
// })), handler)
pub fn validate_params(schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware {
return validator(.param, schema, ...options)
}
// validate_headers - Header 验证中间件的便捷函数
// 使用示例:
// app.use(hono.validate_headers(v_object({
// 'X-API-Key': v_string().required()
// })))
pub fn validate_headers(schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware {
return validator(.header, schema, ...options)
}
// validate_form - Form 数据验证中间件的便捷函数
// 使用示例:
// app.post('/login', hono.validate_form(v_object({
// 'username': v_string().required()
// 'password': v_string().required().min(6)
// })), handler)
pub fn validate_form(schema ObjectSchema, options ...ValidatorOptions) ContextMiddleware {
return validator(.form, schema, ...options)
}
// ============================================================================
// 中间件组合工具
// ============================================================================
// combine_middlewares - 组合多个中间件为一个
// 使用示例:
// combined := hono.combine_middlewares([
// hono.cors_middleware(),
// hono.gzip(),
// hono.rate_limiter(options)
// ])
// app.use(combined)
pub fn combine_middlewares(middlewares []ContextMiddleware) ContextMiddleware {
return fn [middlewares] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 递归执行中间件链
return execute_middleware_chain(0, middlewares, mut c, next)
}
}
// execute_middleware_chain - 递归执行中间件链
fn execute_middleware_chain(idx int, middlewares []ContextMiddleware, mut c Context, final_next fn (mut Context) http.Response) http.Response {
if idx >= middlewares.len {
return final_next(mut c)
}
mw := middlewares[idx]
return mw(mut c, fn [idx, middlewares, final_next] (mut ctx Context) http.Response {
return execute_middleware_chain(idx + 1, middlewares, mut ctx, final_next)
})
}
// ============================================================================
// 预配置中间件工厂
// ============================================================================
// secure_headers - 安全响应头中间件
// 添加常用的安全响应头
pub fn secure_headers() ContextMiddleware {
return fn (mut c Context, next fn (mut Context) http.Response) http.Response {
// 设置安全响应头
c.headers['X-Content-Type-Options'] = 'nosniff'
c.headers['X-Frame-Options'] = 'DENY'
c.headers['X-XSS-Protection'] = '1; mode=block'
c.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
return next(mut c)
}
}
// request_id - 请求 ID 中间件
// 为每个请求生成唯一 ID
pub fn request_id() ContextMiddleware {
return fn (mut c Context, next fn (mut Context) http.Response) http.Response {
// 检查是否已有请求 ID
existing_id := c.req.header.get_custom('X-Request-ID') or { '' }
request_id := if existing_id.len > 0 {
existing_id
} else {
generate_request_id()
}
// 存储到 Context
c.set('request_id', request_id)
// 添加到响应头
c.headers['X-Request-ID'] = request_id
return next(mut c)
}
}
// generate_request_id - 生成简单的请求 ID
fn generate_request_id() string {
timestamp := time.now().unix_milli()
random_part := rand.u32()
return '${timestamp:x}-${random_part:08x}'
}
// timing - 请求计时中间件
// 记录请求处理时间
pub fn timing() ContextMiddleware {
return fn (mut c Context, next fn (mut Context) http.Response) http.Response {
start := time.now()
// 执行后续处理
response := next(mut c)
// 计算耗时
duration := time.since(start)
duration_ms := duration.milliseconds()
// 存储到 Context
c.set('request_duration_ms', duration_ms.str())
// 添加到响应头
c.headers['X-Response-Time'] = '${duration_ms}ms'
return response
}
}
// ============================================================================
// Swagger UI 中间件导出
// ============================================================================
//
// Swagger UI 中间件提供交互式 API 文档界面,支持 OpenAPI 3.0/3.1 规范。
// 主要功能通过 swagger.v 和 openapi.v 文件导出,包括:
//
// 核心函数:
// - swagger_ui(options...) - 创建 Swagger UI 处理器
// - swagger_ui_handler(options...) - swagger_ui 的别名
// - app.doc(path, spec) - 注册 OpenAPI 文档路由
// - app.doc_fn(path, builder) - 使用构建器函数注册文档路由
//
// 类型定义:
// - SwaggerUIOptions - Swagger UI 配置选项
// - OpenAPIDocument - OpenAPI 文档主结构
// - OpenAPIBuilder - 流式 API 构建器
// - OpenAPIInfo, OpenAPIServer, OpenAPIPathItem, OpenAPIOperation 等
//
// 使用示例:
// // 创建 OpenAPI 文档
// spec := hono.OpenAPIBuilder.new()
// .openapi('3.0.0')
// .title('My API')
// .version('1.0.0')
// .build()!
//
// // 注册文档和 UI 路由
// app.doc('/doc', spec)
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
//
// 带自定义选项:
// app.get('/swagger', hono.swagger_ui(hono.SwaggerUIOptions{
// url: '/api/doc'
// title: 'My API Documentation'
// deep_linking: true
// display_request_duration: true
// doc_expansion: 'list'
// try_it_out_enabled: true
// }))
// ============================================================================
// swagger - Swagger UI 处理器的简短别名
// 使用示例:
// app.get('/docs', hono.swagger(hono.SwaggerUIOptions{ url: '/doc' }))
pub fn swagger(options ...SwaggerUIOptions) fn (mut Context) http.Response {
return swagger_ui(...options)
}
// openapi_ui - Swagger UI 处理器的另一个别名
// 使用示例:
// app.get('/api-docs', hono.openapi_ui(hono.SwaggerUIOptions{ url: '/openapi.json' }))
pub fn openapi_ui(options ...SwaggerUIOptions) fn (mut Context) http.Response {
return swagger_ui(...options)
}
// ============================================================================
// WebSocket Helper 导出
// ============================================================================
//
// WebSocket Helper 提供服务端 WebSocket 支持,实现 RFC 6455 协议。
// 主要功能通过 websocket.v 文件导出,包括:
//
// 核心函数:
// - upgrade_websocket(factory, options...) - 创建 WebSocket 升级处理器
// - is_websocket_upgrade(c) - 检查是否为 WebSocket 升级请求
//
// 类型定义:
// - WebSocketOptions - 配置选项
// - WSReadyState - 连接状态枚举
// - WSMessageEvent - 消息事件
// - WSCloseEvent - 关闭事件
// - WSContext - WebSocket 上下文
// - WSEvents - 事件处理器配置
// - WSHandlerFactory - 处理器工厂类型
//
// 常量:
// - ws_opcode_* - WebSocket 操作码
// - ws_close_* - WebSocket 关闭状态码
//
// 帧处理函数:
// - encode_ws_frame() - 编码 WebSocket 帧
// - decode_ws_frame() - 解码 WebSocket 帧
// - compute_accept_key() - 计算握手响应密钥
//
// 使用示例:
// app.get('/ws', hono.upgrade_websocket(fn (c hono.Context) hono.WSEvents {
// return hono.WSEvents{
// on_open: fn (mut ws hono.WSContext) {
// ws.send('Welcome!') or {}
// }
// on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
// println('Closed: ${event.code}')
// }
// }
// }))
//
// 带配置选项:
// app.get('/ws', hono.upgrade_websocket(factory, hono.WebSocketOptions{
// ping_interval: 30000 // 30秒 ping 间隔
// max_message_size: 1048576 // 1MB 最大消息大小
// timeout: 60000 // 60秒超时
// protocols: ['chat', 'json'] // 支持的子协议
// }))
// ============================================================================
// websocket - WebSocket 升级处理器的别名函数
// 使用示例:
// app.get('/ws', hono.websocket(fn (c hono.Context) hono.WSEvents {
// return hono.WSEvents{
// on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// }
// }))
pub fn websocket(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response {
return upgrade_websocket(factory, ...options)
}
// ws - WebSocket 升级处理器的简短别名
// 使用示例:
// app.get('/ws', hono.ws(handler_factory))
pub fn ws(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response {
return upgrade_websocket(factory, ...options)
}
// is_ws_upgrade - 检查请求是否为 WebSocket 升级请求的别名
// 使用示例:
// if hono.is_ws_upgrade(c) {
// // Handle WebSocket upgrade
// }
pub fn is_ws_upgrade(c Context) bool {
return is_websocket_upgrade(c)
}
+1822
View File
File diff suppressed because it is too large Load Diff
+471 -45
View File
@@ -61,6 +61,135 @@ pub fn (mut app Hono) listen_picoev_with_config(config PicoevConfig) {
pico.serve()
}
// ============================================================================
// WebSocket Upgrade Detection and Handling
// ============================================================================
// Check if a picoev request is a WebSocket upgrade request
fn is_picoev_ws_upgrade(req picohttpparser.Request) bool {
mut has_upgrade := false
mut has_connection := false
mut has_ws_key := false
for i in 0 .. req.num_headers {
h := req.headers[i]
name_lower := h.name.to_lower()
if name_lower == 'upgrade' {
if h.value.to_lower() == 'websocket' {
has_upgrade = true
}
} else if name_lower == 'connection' {
if contains_ignore_case(h.value, 'upgrade') {
has_connection = true
}
} else if name_lower == 'sec-websocket-key' {
if h.value.len > 0 {
has_ws_key = true
}
}
}
return has_upgrade && has_connection && has_ws_key
}
// Get WebSocket key from picoev request headers
fn get_picoev_ws_key(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-key') {
return h.value
}
}
return ''
}
// Get WebSocket protocol from picoev request headers
fn get_picoev_ws_protocol(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-protocol') {
return h.value
}
}
return ''
}
// Get WebSocket version from picoev request headers
fn get_picoev_ws_version(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-version') {
return h.value
}
}
return '13'
}
// Handle WebSocket upgrade for picoev
fn handle_picoev_ws_upgrade(mut ctx PicoevRequestContext, req picohttpparser.Request, mut res picohttpparser.Response, route_match ContextRouteMatch, hono_ctx Context) bool {
// Validate WebSocket version
ws_version := get_picoev_ws_version(req)
if ws_version != '13' {
res.raw('HTTP/1.1 426 Upgrade Required\r\n')
res.header('Sec-WebSocket-Version', '13')
res.header('Content-Type', 'text/plain')
res.body('Unsupported WebSocket version')
res.end()
return false
}
// Get WebSocket key
ws_key := get_picoev_ws_key(req)
if ws_key.len == 0 {
res.raw('HTTP/1.1 400 Bad Request\r\n')
res.header('Content-Type', 'text/plain')
res.body('Missing Sec-WebSocket-Key header')
res.end()
return false
}
// Compute accept key
accept_key := compute_accept_key(ws_key)
// Execute the route handler to get WSEvents
// The handler should return a 101 response with WebSocket context stored
mut mutable_ctx := hono_ctx
response := route_match.handler.handle(mut mutable_ctx)
// Check if this is a WebSocket upgrade response
if response.status_code != 101 {
// Not a WebSocket upgrade, send the response as-is
send_picoev_response(mut res, mutable_ctx, response, false, ctx.config)
return false
}
// Negotiate subprotocol
mut selected_protocol := ''
if '_ws_protocol' in mutable_ctx.store {
selected_protocol = mutable_ctx.store['_ws_protocol']
}
// Send WebSocket handshake response
res.raw('HTTP/1.1 101 Switching Protocols\r\n')
res.header('Upgrade', 'websocket')
res.header('Connection', 'Upgrade')
res.header('Sec-WebSocket-Accept', accept_key)
if selected_protocol.len > 0 {
res.header('Sec-WebSocket-Protocol', selected_protocol)
}
res.body('')
res.end()
return true
}
// Send WebSocket frame via picoev response
fn send_ws_frame_picoev(data []u8) ! {
// This is a placeholder - actual implementation depends on picoev's raw socket access
// In practice, we need to write directly to the socket fd
}
// picoev 回调函数
fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picohttpparser.Response) {
mut ctx := unsafe { &PicoevRequestContext(user_data) }
@@ -75,11 +204,32 @@ fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picoht
keepalive := check_keepalive_request(req)
method_str := req.method
// Check if this is a WebSocket upgrade request
is_ws_upgrade := is_picoev_ws_upgrade(req)
// 优先使用快速路由器
if ctx.app.use_fast_router {
if route_match := ctx.app.fast_router.match_route(method_str, path) {
mut hono_ctx := create_picoev_context(req, route_match.params, query_map)
middlewares := get_middlewares_for_path_picoev(ctx.app, path)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
if handle_picoev_ws_upgrade(mut ctx, req, mut res, route_match, hono_ctx) {
// WebSocket upgrade successful, connection is now in WebSocket mode
return
}
// If upgrade failed, response was already sent
return
}
// 优化:零中间件快速路径
if !ctx.app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
send_picoev_response(mut res, hono_ctx, response, keepalive, ctx.config)
return
}
middlewares := get_middlewares_for_path_picoev_optimized(ctx.app, path)
response := exec_middlewares_picoev(0, middlewares, mut hono_ctx, route_match.handler)
send_picoev_response(mut res, hono_ctx, response, keepalive, ctx.config)
return
@@ -89,7 +239,24 @@ fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picoht
// 回退到混合路由器
if route_match := ctx.app.context_hybrid_router.match_route(method_str, path) {
mut hono_ctx := create_picoev_context(req, route_match.params, query_map)
middlewares := get_middlewares_for_path_picoev(ctx.app, path)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
if handle_picoev_ws_upgrade(mut ctx, req, mut res, route_match, hono_ctx) {
// WebSocket upgrade successful
return
}
return
}
// 优化:零中间件快速路径
if !ctx.app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
send_picoev_response(mut res, hono_ctx, response, keepalive, ctx.config)
return
}
middlewares := get_middlewares_for_path_picoev_optimized(ctx.app, path)
response := exec_middlewares_picoev(0, middlewares, mut hono_ctx, route_match.handler)
send_picoev_response(mut res, hono_ctx, response, keepalive, ctx.config)
return
@@ -118,25 +285,76 @@ fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picoht
res.end()
}
// 检查客户端是否请求 Keep-Alive
// 检查客户端是否请求 Keep-Alive - 优化版
// 避免 to_lower() 创建新字符串,使用大小写不敏感比较
fn check_keepalive_request(req picohttpparser.Request) bool {
for i in 0 .. req.num_headers {
h := req.headers[i]
if h.name.to_lower() == 'connection' {
return h.value.to_lower().contains('keep-alive')
// 大小写不敏感比较 "connection"
if h.name.len == 10 && eq_ignore_case(h.name, 'connection') {
// 检查是否包含 "keep-alive"(大小写不敏感)
return contains_ignore_case(h.value, 'keep-alive')
}
}
return true // HTTP/1.1 默认 Keep-Alive
}
// 获取路径对应的所有中间件
fn get_middlewares_for_path_picoev(app &Hono, path string) []ContextMiddleware {
// 大小写不敏感字符串比较(避免分配)
@[inline]
fn eq_ignore_case(a string, b string) bool {
if a.len != b.len {
return false
}
for i in 0 .. a.len {
ca := a[i]
cb := b[i]
// 转换为小写比较
la := if ca >= `A` && ca <= `Z` { ca + 32 } else { ca }
lb := if cb >= `A` && cb <= `Z` { cb + 32 } else { cb }
if la != lb {
return false
}
}
return true
}
// 大小写不敏感的 contains 检查(避免分配)
@[inline]
fn contains_ignore_case(haystack string, needle string) bool {
if needle.len > haystack.len {
return false
}
max_start := haystack.len - needle.len
for i := 0; i <= max_start; i++ {
mut found := true
for j in 0 .. needle.len {
ch := haystack[i + j]
cn := needle[j]
lh := if ch >= `A` && ch <= `Z` { ch + 32 } else { ch }
ln := if cn >= `A` && cn <= `Z` { cn + 32 } else { cn }
if lh != ln {
found = false
break
}
}
if found {
return true
}
}
return false
}
// 获取路径对应的所有中间件(优化版:使用预排序的前缀列表)
fn get_middlewares_for_path_picoev_optimized(app &Hono, path string) []ContextMiddleware {
// 优化:只有全局中间件时,直接返回引用(避免克隆)
if app.route_middlewares.len == 0 {
return app.context_middlewares
}
mut middlewares := app.context_middlewares.clone()
mut prefixes := app.route_middlewares.keys()
prefixes.sort(a.len < b.len)
for prefix in prefixes {
// 优化:使用预排序的前缀列表(启动时已排序,不需要每次请求都排序)
for prefix in app.sorted_middleware_prefixes {
if path.starts_with(prefix) || prefix == '/' {
if mws := app.route_middlewares[prefix] {
middlewares << mws
@@ -147,6 +365,11 @@ fn get_middlewares_for_path_picoev(app &Hono, path string) []ContextMiddleware {
return middlewares
}
// 获取路径对应的所有中间件(保留旧版本兼容)
fn get_middlewares_for_path_picoev(app &Hono, path string) []ContextMiddleware {
return get_middlewares_for_path_picoev_optimized(app, path)
}
// 执行中间件链
fn exec_middlewares_picoev(idx int, middlewares []ContextMiddleware, mut ctx Context, handler IHandler) http.Response {
if idx < middlewares.len {
@@ -158,40 +381,96 @@ fn exec_middlewares_picoev(idx int, middlewares []ContextMiddleware, mut ctx Con
return handler.handle(mut ctx)
}
// 解析路径和查询参数
// 解析路径和查询参数 - 零分配优化版
// 使用指针遍历避免创建临时数组
fn parse_path_and_query(full_path string) (string, map[string]string) {
mut query_map := map[string]string{}
len := full_path.len
if idx := full_path.index('?') {
path := full_path[..idx]
query_str := full_path[idx + 1..]
for part in query_str.split('&') {
if eq_idx := part.index('=') {
query_map[part[..eq_idx]] = part[eq_idx + 1..]
}
}
return path, query_map
if len == 0 {
return full_path, query_map
}
return full_path, query_map
// 快速路径:大多数请求没有查询参数
// 从后向前搜索 '?' 通常更快(查询参数在末尾)
mut query_start := -1
for i := len - 1; i >= 0; i-- {
if full_path[i] == `?` {
query_start = i
break
}
}
// 没有查询参数,直接返回(最常见情况)
if query_start == -1 {
return full_path, query_map
}
path := full_path[..query_start]
// 解析查询参数(单次遍历,避免 split)
mut key_start := query_start + 1
mut key_end := -1
mut value_start := -1
for i := query_start + 1; i <= len; i++ {
ch := if i < len { full_path[i] } else { `&` } // 末尾视为分隔符
if ch == `=` && key_end == -1 {
key_end = i
value_start = i + 1
} else if ch == `&` {
// 完成一个键值对
if key_end > key_start && value_start > 0 {
key := full_path[key_start..key_end]
value := if value_start < i { full_path[value_start..i] } else { '' }
query_map[key] = value
} else if key_end == -1 && i > key_start {
// 只有 key 没有 value(如 ?foo&bar=1)
key := full_path[key_start..i]
query_map[key] = ''
}
// 重置状态
key_start = i + 1
key_end = -1
value_start = -1
}
}
return path, query_map
}
// 创建 picoev 上下文
// 创建 picoev 上下文 - 优化版
// 延迟转换 http.Request,只在真正需要时才转换
fn create_picoev_context(req picohttpparser.Request, params map[string]string, query map[string]string) Context {
// 提取纯路径(不含查询参数)
path := extract_path_only(req.path)
return Context{
req: convert_picoev_request(req)
params: params
query: query
body: req.body
url: req.path
path: req.path
path: path
status_code: 200
headers: map[string]string{}
}
}
// 转换 picoev 请求
// 快速提取路径(不含查询参数)
@[inline]
fn extract_path_only(full_path string) string {
for i in 0 .. full_path.len {
if full_path[i] == `?` {
return full_path[..i]
}
}
return full_path
}
// 转换 picoev 请求 - 优化版
// 使用预计算的方法映射避免字符串比较
fn convert_picoev_request(req picohttpparser.Request) http.Request {
mut headers := http.new_header()
@@ -201,24 +480,54 @@ fn convert_picoev_request(req picohttpparser.Request) http.Request {
}
return http.Request{
method: match req.method {
'GET' { http.Method.get }
'POST' { http.Method.post }
'PUT' { http.Method.put }
'DELETE' { http.Method.delete }
'PATCH' { http.Method.patch }
'HEAD' { http.Method.head }
'OPTIONS' { http.Method.options }
else { http.Method.get }
}
method: parse_http_method_fast(req.method)
url: req.path
data: req.body
header: headers
}
}
// 发送 picoev 响应
// 快速 HTTP 方法解析(基于首字符和长度)
@[inline]
fn parse_http_method_fast(method string) http.Method {
len := method.len
if len == 0 {
return http.Method.get
}
// 基于首字符快速分支
match method[0] {
`G` {
if len == 3 { return http.Method.get }
}
`P` {
if len == 4 && method[1] == `O` { return http.Method.post }
if len == 3 && method[1] == `U` { return http.Method.put }
if len == 5 { return http.Method.patch }
}
`D` {
if len == 6 { return http.Method.delete }
}
`H` {
if len == 4 { return http.Method.head }
}
`O` {
if len == 7 { return http.Method.options }
}
else {}
}
return http.Method.get
}
// 发送 picoev 响应 - 优化版
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
// Check if this is a streaming response
if is_streaming_response(ctx) {
// Handle streaming response
handle_picoev_streaming_response(mut res, ctx, config)
return
}
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
if status_code == 200 {
@@ -231,18 +540,21 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
mut has_connection := false
for key, value in ctx.headers {
// 跳过 Content-Length,picohttpparser 会在 body() 时自动添加
if key.to_lower() == 'content-length' {
continue
// 使用快速大小写不敏感比较
key_len := key.len
if key_len == 14 && eq_ignore_case(key, 'content-length') {
continue // 跳过 Content-Length
}
res.header(key, value)
key_lower := key.to_lower()
if key_lower == 'content-type' { has_content_type = true }
if key_lower == 'connection' { has_connection = true }
if key_len == 12 && eq_ignore_case(key, 'content-type') {
has_content_type = true
} else if key_len == 10 && eq_ignore_case(key, 'connection') {
has_connection = true
}
}
if content_type := response.header.get(.content_type) {
if !has_content_type {
if !has_content_type {
if content_type := response.header.get(.content_type) {
res.header('Content-Type', content_type)
has_content_type = true
}
@@ -265,9 +577,122 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
res.end()
}
// ============================================================================
// Streaming Response Handling for Picoev
// ============================================================================
// handle_picoev_streaming_response - Handle a streaming response using PicoevStreamWriter
// This function:
// 1. Sends the HTTP headers for streaming directly to socket fd
// 2. Creates a PicoevStreamWriter with the socket fd
// 3. Executes the streaming callback
// 4. Handles errors and cleanup
fn handle_picoev_streaming_response(mut res picohttpparser.Response, ctx Context, config PicoevConfig) {
// Get stream configuration
stream_config := get_stream_config(ctx) or {
// Fallback to error response if stream config not found
res.raw('HTTP/1.1 500 Internal Server Error\r\n')
res.header('Content-Type', 'text/plain')
res.body('Stream configuration not found')
res.end()
return
}
// Determine status code
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
// Build HTTP headers string for direct socket write
mut headers_str := 'HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n'
// Add headers from context
mut has_transfer_encoding := false
mut has_connection := false
for key, value in ctx.headers {
key_len := key.len
// Skip Content-Length for streaming (we use chunked encoding)
if key_len == 14 && eq_ignore_case(key, 'content-length') {
continue
}
headers_str += '${key}: ${value}\r\n'
if key_len == 17 && eq_ignore_case(key, 'transfer-encoding') {
has_transfer_encoding = true
} else if key_len == 10 && eq_ignore_case(key, 'connection') {
has_connection = true
}
}
// Ensure Transfer-Encoding: chunked is set
if !has_transfer_encoding {
headers_str += 'Transfer-Encoding: chunked\r\n'
}
// Ensure Connection header is set for streaming
if !has_connection {
headers_str += 'Connection: keep-alive\r\n'
headers_str += 'Keep-Alive: timeout=${config.timeout_secs}\r\n'
}
// End headers section
headers_str += '\r\n'
// Write headers directly to socket fd for immediate delivery
fd := res.fd
write_to_socket_fd(fd, headers_str.bytes()) or {
eprintln('[Picoev Stream Error] Failed to write headers: ${err.msg()}')
cleanup_stream_config(ctx)
return
}
// Create PicoevStreamWriter for streaming (uses fd directly)
mut writer := PicoevStreamWriter.new(res)
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the streaming callback
stream_config.callback(mut stream_ctx) or {
// Handle error
if error_handler := stream_config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[Picoev Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
}
// write_to_socket_fd - Helper function to write bytes directly to socket fd
// Used for sending HTTP headers before streaming begins
fn write_to_socket_fd(fd int, data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(fd, ptr, remaining, 0)
if written < 0 {
return error('Failed to write to socket')
}
if written == 0 {
return error('Connection closed by peer')
}
total_written += written
}
}
// 获取状态码文本
fn get_status_text(code int) string {
return match code {
101 { 'Switching Protocols' }
200 { 'OK' }
201 { 'Created' }
204 { 'No Content' }
@@ -279,6 +704,7 @@ fn get_status_text(code int) string {
403 { 'Forbidden' }
404 { 'Not Found' }
405 { 'Method Not Allowed' }
426 { 'Upgrade Required' }
500 { 'Internal Server Error' }
502 { 'Bad Gateway' }
503 { 'Service Unavailable' }
+225
View File
@@ -0,0 +1,225 @@
module hono
import net.http
import time
import sync
// RateLimitEntry 结构体 - 限流条目
pub struct RateLimitEntry {
pub mut:
count int // 当前请求计数
reset_at i64 // 重置时间(Unix 毫秒时间戳)
}
// RateLimitStore 接口 - 限流存储后端
pub interface RateLimitStore {
mut:
// increment - 增加计数并返回当前计数和重置时间
// 参数: key - 客户端标识, window_ms - 时间窗口(毫秒)
// 返回: (当前计数, 重置时间戳)
increment(key string, window_ms i64) (int, i64)
// reset - 重置指定 key 的计数
reset(key string)
}
// MemoryStore 结构体 - 内存存储实现
pub struct MemoryStore {
mut:
data map[string]RateLimitEntry
mtx sync.Mutex // 线程安全锁
}
// MemoryStore.new - 创建新的内存存储
pub fn MemoryStore.new() &MemoryStore {
return &MemoryStore{
data: map[string]RateLimitEntry{}
}
}
// increment - 增加计数并返回当前计数和重置时间
pub fn (mut s MemoryStore) increment(key string, window_ms i64) (int, i64) {
s.mtx.@lock()
defer { s.mtx.unlock() }
now := time.now().unix_milli()
// 检查是否存在条目
if key in s.data {
mut entry := s.data[key]
// 检查是否需要重置(窗口已过期)
if now >= entry.reset_at {
// 窗口已过期,重置计数
entry.count = 1
entry.reset_at = now + window_ms
} else {
// 窗口内,增加计数
entry.count++
}
s.data[key] = entry
return entry.count, entry.reset_at
}
// 新条目
new_entry := RateLimitEntry{
count: 1
reset_at: now + window_ms
}
s.data[key] = new_entry
return new_entry.count, new_entry.reset_at
}
// reset - 重置指定 key 的计数
pub fn (mut s MemoryStore) reset(key string) {
s.mtx.@lock()
defer { s.mtx.unlock() }
s.data.delete(key)
}
// get_entry - 获取指定 key 的条目(用于测试)
pub fn (s MemoryStore) get_entry(key string) ?RateLimitEntry {
if key in s.data {
return s.data[key]
}
return none
}
// cleanup_expired - 清理过期条目(可选的维护方法)
pub fn (mut s MemoryStore) cleanup_expired() {
s.mtx.@lock()
defer { s.mtx.unlock() }
now := time.now().unix_milli()
mut keys_to_delete := []string{}
for key, entry in s.data {
if now >= entry.reset_at {
keys_to_delete << key
}
}
for key in keys_to_delete {
s.data.delete(key)
}
}
// RateLimitOptions 结构体 - 限流配置选项
pub struct RateLimitOptions {
pub:
window_ms i64 = 60000 // 时间窗口(毫秒),默认 1 分钟
limit int = 100 // 窗口内最大请求数,默认 100
key_generator ?fn (Context) string // 客户端标识生成器
skip ?fn (Context) bool // 跳过限流条件
handler ?fn (mut Context, RateLimitInfo) http.Response // 自定义限流响应
store &MemoryStore // 存储后端(必需)
headers bool = true // 是否添加限流头,默认 true
}
// RateLimitInfo 结构体 - 限流信息(传递给自定义 handler)
pub struct RateLimitInfo {
pub:
limit int // 最大请求数
remaining int // 剩余请求数
reset_at i64 // 重置时间戳(毫秒)
}
// rate_limit - 限流中间件工厂函数
// 返回一个 ContextMiddleware,用于限制请求频率
// 注意:必须提供 store 参数
pub fn rate_limit(options RateLimitOptions) ContextMiddleware {
mut store := options.store
return fn [options, mut store] (mut c Context, next fn (mut Context) http.Response) http.Response {
// 检查是否跳过限流
if skip_fn := options.skip {
if skip_fn(c) {
return next(mut c)
}
}
// 生成客户端标识 key
key := generate_rate_limit_key(c, options.key_generator)
// 增加计数
count, reset_at := store.increment(key, options.window_ms)
// 计算剩余请求数
remaining := if count > options.limit { 0 } else { options.limit - count }
// 设置限流响应头
if options.headers {
set_rate_limit_headers(mut c, options.limit, remaining, reset_at)
}
// 检查是否超过限制
if count > options.limit {
// 创建限流信息
info := RateLimitInfo{
limit: options.limit
remaining: 0
reset_at: reset_at
}
// 使用自定义 handler 或默认响应
if custom_handler := options.handler {
return custom_handler(mut c, info)
}
return rate_limit_exceeded_response(mut c, reset_at)
}
// 继续处理请求
return next(mut c)
}
}
// generate_rate_limit_key - 生成客户端标识 key
fn generate_rate_limit_key(c Context, key_generator ?fn (Context) string) string {
// 使用自定义 key 生成器
if gen_fn := key_generator {
return gen_fn(c)
}
// 默认使用客户端 IP
return c.get_client_ip()
}
// set_rate_limit_headers - 设置限流响应头
fn set_rate_limit_headers(mut c Context, limit int, remaining int, reset_at i64) {
c.headers['X-RateLimit-Limit'] = limit.str()
c.headers['X-RateLimit-Remaining'] = remaining.str()
// 将毫秒时间戳转换为秒(HTTP 标准)
c.headers['X-RateLimit-Reset'] = (reset_at / 1000).str()
}
// rate_limit_exceeded_response - 返回 429 限流响应
fn rate_limit_exceeded_response(mut c Context, reset_at i64) http.Response {
c.status(429)
// 计算 Retry-After(秒)
now := time.now().unix_milli()
retry_after := if reset_at > now { (reset_at - now) / 1000 } else { i64(0) }
if retry_after > 0 {
c.headers['Retry-After'] = retry_after.str()
}
return c.json('{"error":"Too Many Requests","message":"Rate limit exceeded. Please try again later."}')
}
// get_rate_limit_info - 从 Context 获取限流信息
// 这是一个便捷方法,用于在 handler 中获取当前请求的限流状态
pub fn get_rate_limit_info(c Context) ?RateLimitInfo {
limit_str := c.headers['X-RateLimit-Limit'] or { return none }
remaining_str := c.headers['X-RateLimit-Remaining'] or { return none }
reset_str := c.headers['X-RateLimit-Reset'] or { return none }
return RateLimitInfo{
limit: limit_str.int()
remaining: remaining_str.int()
reset_at: reset_str.i64() * 1000 // 转换回毫秒
}
}
+44
View File
@@ -17,6 +17,7 @@ pub mut:
status_code int = 200
headers map[string]string
body string
store map[string]string // 中间件数据存储
}
// Context 构造函数
@@ -35,6 +36,7 @@ pub fn Context.new(req http.Request, params map[string]string, query map[string]
url: url.str() // 设置为完整的URL字符串
path: url.path // 设置 path 属性
headers: map[string]string{}
store: map[string]string{} // 初始化中间件数据存储
}
}
@@ -256,6 +258,48 @@ pub fn (mut c Context) status(code int) {
c.status_code = code
}
// get 方法 - 从 store 中获取中间件数据
pub fn (c Context) get(key string) ?string {
if key in c.store {
return c.store[key]
}
return none
}
// set 方法 - 向 store 中存储中间件数据
pub fn (mut c Context) set(key string, value string) {
c.store[key] = value
}
// get_client_ip 方法 - 获取客户端 IP 地址
// 优先从 X-Forwarded-For 和 X-Real-IP 头获取,否则从连接信息获取
pub fn (c Context) get_client_ip() string {
// 尝试从 X-Forwarded-For 头获取(代理场景)
if forwarded_for := c.req.header.get_custom('X-Forwarded-For') {
// X-Forwarded-For 可能包含多个 IP,取第一个
ips := forwarded_for.split(',')
if ips.len > 0 {
ip := ips[0].trim_space()
if ip != '' {
return ip
}
}
}
// 尝试从 X-Real-IP 头获取
if real_ip := c.req.header.get_custom('X-Real-IP') {
trimmed := real_ip.trim_space()
if trimmed != '' {
return trimmed
}
}
// 从请求 URL 或连接信息获取
// 注意:V 语言的 http.Request 可能没有直接的远程地址字段
// 这里返回一个默认值,实际使用时可能需要根据服务器实现调整
return '127.0.0.1'
}
// redirect method - redirect to a URL with optional status code
pub fn (mut c Context) redirect(url string, status_code ...int) http.Response {
// Set default status code to 302 (Found) if not provided
+557
View File
@@ -0,0 +1,557 @@
module hono
import time
import rand
import math
// ============================================================================
// 重试配置
// ============================================================================
// 重试配置结构
pub struct RetryConfig {
pub:
max_retries int = 3 // 最大重试次数
initial_delay int = 1000 // 初始延迟(毫秒)
max_delay int = 30000 // 最大延迟(毫秒)
multiplier f64 = 2.0 // 延迟倍增因子
jitter bool = true // 是否添加随机抖动
jitter_factor f64 = 0.25 // 抖动因子 (0.0 - 1.0)
}
// 默认重试配置
pub fn default_retry_config() RetryConfig {
return RetryConfig{
max_retries: 3
initial_delay: 1000
max_delay: 30000
multiplier: 2.0
jitter: true
jitter_factor: 0.25
}
}
// 激进重试配置(更多重试,更短延迟)
pub fn aggressive_retry_config() RetryConfig {
return RetryConfig{
max_retries: 5
initial_delay: 500
max_delay: 10000
multiplier: 1.5
jitter: true
jitter_factor: 0.2
}
}
// 保守重试配置(更少重试,更长延迟)
pub fn conservative_retry_config() RetryConfig {
return RetryConfig{
max_retries: 2
initial_delay: 2000
max_delay: 60000
multiplier: 3.0
jitter: true
jitter_factor: 0.3
}
}
// ============================================================================
// 重试结果
// ============================================================================
// 重试执行结果
pub struct RetryResult[T] {
pub:
success bool
value T
total_retries int
total_delay int // 总延迟时间(毫秒)
final_error StorageError
history []RetryAttempt
}
// 创建成功的重试结果
fn new_retry_success[T](value T, total_retries int, total_delay int, history []RetryAttempt) RetryResult[T] {
return RetryResult[T]{
success: true
value: value
total_retries: total_retries
total_delay: total_delay
final_error: StorageError{}
history: history
}
}
// 创建失败的重试结果
fn new_retry_failure[T](final_error StorageError, total_retries int, total_delay int, history []RetryAttempt) RetryResult[T] {
return RetryResult[T]{
success: false
value: T{}
total_retries: total_retries
total_delay: total_delay
final_error: final_error
history: history
}
}
// ============================================================================
// 重试执行器
// ============================================================================
// 重试执行器
pub struct RetryExecutor {
config RetryConfig
}
// 创建重试执行器
pub fn new_retry_executor(config RetryConfig) RetryExecutor {
return RetryExecutor{
config: config
}
}
// 使用默认配置创建重试执行器
pub fn new_default_retry_executor() RetryExecutor {
return RetryExecutor{
config: default_retry_config()
}
}
// 计算下一次重试的延迟时间
pub fn (r RetryExecutor) calculate_delay(attempt int) int {
// 指数退避: delay = initial_delay * (multiplier ^ attempt)
base_delay := f64(r.config.initial_delay) * math.pow(r.config.multiplier, f64(attempt))
// 限制最大延迟
mut delay := int(math.min(base_delay, f64(r.config.max_delay)))
// 添加随机抖动
if r.config.jitter && delay > 0 {
jitter_range := int(f64(delay) * r.config.jitter_factor)
if jitter_range > 0 {
jitter := rand.int_in_range(0, jitter_range * 2) or { 0 }
delay = delay - jitter_range + jitter
}
}
// 确保延迟不为负
if delay < 0 {
delay = 0
}
return delay
}
// 执行带重试的操作(返回 []u8)
pub fn (r RetryExecutor) execute_bytes(operation fn () ![]u8, provider string, op_name string) RetryResult[[]u8] {
mut history := []RetryAttempt{}
mut total_delay := 0
mut last_error := StorageError{}
for attempt in 0 .. r.config.max_retries + 1 {
// 执行操作
result := operation() or {
// 解析错误
error_msg := err.msg()
storage_err := parse_error_message(error_msg, provider, op_name)
// 检查是否可重试
if !storage_err.is_retryable() {
// 不可重试错误,立即返回
return new_retry_failure[[]u8](storage_err, attempt, total_delay, history)
}
last_error = storage_err
// 如果还有重试机会
if attempt < r.config.max_retries {
delay := r.calculate_delay(attempt)
// 记录重试尝试
history << RetryAttempt{
attempt_number: attempt + 1
timestamp: time.now().unix()
error_message: error_msg
delay_ms: delay
}
total_delay += delay
// 等待后重试
if delay > 0 {
time.sleep(delay * time.millisecond)
}
}
continue
}
// 操作成功
return new_retry_success[[]u8](result, attempt, total_delay, history)
}
// 所有重试都失败
mut final_error := last_error
final_error.retry_history = history
return new_retry_failure[[]u8](final_error, r.config.max_retries, total_delay, history)
}
// 执行带重试的操作(返回 StorageResult)
pub fn (r RetryExecutor) execute_storage_result(operation fn () !StorageResult, provider string, op_name string) RetryResult[StorageResult] {
mut history := []RetryAttempt{}
mut total_delay := 0
mut last_error := StorageError{}
for attempt in 0 .. r.config.max_retries + 1 {
// 执行操作
result := operation() or {
// 解析错误
error_msg := err.msg()
storage_err := parse_error_message(error_msg, provider, op_name)
// 检查是否可重试
if !storage_err.is_retryable() {
// 不可重试错误,立即返回
return new_retry_failure[StorageResult](storage_err, attempt, total_delay, history)
}
last_error = storage_err
// 如果还有重试机会
if attempt < r.config.max_retries {
delay := r.calculate_delay(attempt)
// 记录重试尝试
history << RetryAttempt{
attempt_number: attempt + 1
timestamp: time.now().unix()
error_message: error_msg
delay_ms: delay
}
total_delay += delay
// 等待后重试
if delay > 0 {
time.sleep(delay * time.millisecond)
}
}
continue
}
// 操作成功
return new_retry_success[StorageResult](result, attempt, total_delay, history)
}
// 所有重试都失败
mut final_error := last_error
final_error.retry_history = history
return new_retry_failure[StorageResult](final_error, r.config.max_retries, total_delay, history)
}
// 执行带重试的操作(返回 bool)
pub fn (r RetryExecutor) execute_bool(operation fn () !bool, provider string, op_name string) RetryResult[bool] {
mut history := []RetryAttempt{}
mut total_delay := 0
mut last_error := StorageError{}
for attempt in 0 .. r.config.max_retries + 1 {
// 执行操作
result := operation() or {
// 解析错误
error_msg := err.msg()
storage_err := parse_error_message(error_msg, provider, op_name)
// 检查是否可重试
if !storage_err.is_retryable() {
// 不可重试错误,立即返回
return new_retry_failure[bool](storage_err, attempt, total_delay, history)
}
last_error = storage_err
// 如果还有重试机会
if attempt < r.config.max_retries {
delay := r.calculate_delay(attempt)
// 记录重试尝试
history << RetryAttempt{
attempt_number: attempt + 1
timestamp: time.now().unix()
error_message: error_msg
delay_ms: delay
}
total_delay += delay
// 等待后重试
if delay > 0 {
time.sleep(delay * time.millisecond)
}
}
continue
}
// 操作成功
return new_retry_success[bool](result, attempt, total_delay, history)
}
// 所有重试都失败
mut final_error := last_error
final_error.retry_history = history
return new_retry_failure[bool](final_error, r.config.max_retries, total_delay, history)
}
// 执行带重试的操作(无返回值)
pub fn (r RetryExecutor) execute_void(operation fn () !, provider string, op_name string) RetryResult[bool] {
mut history := []RetryAttempt{}
mut total_delay := 0
mut last_error := StorageError{}
for attempt in 0 .. r.config.max_retries + 1 {
// 执行操作
operation() or {
// 解析错误
error_msg := err.msg()
storage_err := parse_error_message(error_msg, provider, op_name)
// 检查是否可重试
if !storage_err.is_retryable() {
// 不可重试错误,立即返回
return new_retry_failure[bool](storage_err, attempt, total_delay, history)
}
last_error = storage_err
// 如果还有重试机会
if attempt < r.config.max_retries {
delay := r.calculate_delay(attempt)
// 记录重试尝试
history << RetryAttempt{
attempt_number: attempt + 1
timestamp: time.now().unix()
error_message: error_msg
delay_ms: delay
}
total_delay += delay
// 等待后重试
if delay > 0 {
time.sleep(delay * time.millisecond)
}
}
continue
}
// 操作成功
return new_retry_success[bool](true, attempt, total_delay, history)
}
// 所有重试都失败
mut final_error := last_error
final_error.retry_history = history
return new_retry_failure[bool](final_error, r.config.max_retries, total_delay, history)
}
// 执行带重试的操作(返回 string)
pub fn (r RetryExecutor) execute_string(operation fn () !string, provider string, op_name string) RetryResult[string] {
mut history := []RetryAttempt{}
mut total_delay := 0
mut last_error := StorageError{}
for attempt in 0 .. r.config.max_retries + 1 {
// 执行操作
result := operation() or {
// 解析错误
error_msg := err.msg()
storage_err := parse_error_message(error_msg, provider, op_name)
// 检查是否可重试
if !storage_err.is_retryable() {
// 不可重试错误,立即返回
return new_retry_failure[string](storage_err, attempt, total_delay, history)
}
last_error = storage_err
// 如果还有重试机会
if attempt < r.config.max_retries {
delay := r.calculate_delay(attempt)
// 记录重试尝试
history << RetryAttempt{
attempt_number: attempt + 1
timestamp: time.now().unix()
error_message: error_msg
delay_ms: delay
}
total_delay += delay
// 等待后重试
if delay > 0 {
time.sleep(delay * time.millisecond)
}
}
continue
}
// 操作成功
return new_retry_success[string](result, attempt, total_delay, history)
}
// 所有重试都失败
mut final_error := last_error
final_error.retry_history = history
return new_retry_failure[string](final_error, r.config.max_retries, total_delay, history)
}
// ============================================================================
// 辅助函数
// ============================================================================
// 从错误消息解析 StorageError
fn parse_error_message(error_msg string, provider string, operation string) StorageError {
// 尝试从错误消息中提取信息
// 格式: "provider/operation: message (kind: xxx, http_status: yyy)"
// 检查是否包含已知的错误类型关键字
kind := detect_error_kind_from_message(error_msg)
// 尝试提取 HTTP 状态码
http_status := extract_http_status_from_message(error_msg)
return StorageError{
kind: kind
message: error_msg
provider: provider
operation: operation
http_status: http_status
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 从错误消息检测错误类型
fn detect_error_kind_from_message(msg string) StorageErrorKind {
lower_msg := msg.to_lower()
// 可重试错误
if lower_msg.contains('timeout') || lower_msg.contains('timed out') {
return .network_timeout
}
if lower_msg.contains('service unavailable') || lower_msg.contains('503') {
return .service_unavailable
}
if lower_msg.contains('rate limit') || lower_msg.contains('too many requests') || lower_msg.contains('429') {
return .rate_limited
}
if lower_msg.contains('connection reset') || lower_msg.contains('connection refused') {
return .connection_reset
}
if lower_msg.contains('temporary') || lower_msg.contains('retry') {
return .temporary_failure
}
// 不可重试错误
if lower_msg.contains('invalid credentials') || lower_msg.contains('401') || lower_msg.contains('unauthorized') {
return .invalid_credentials
}
if lower_msg.contains('access denied') || lower_msg.contains('403') || lower_msg.contains('forbidden') {
return .access_denied
}
if lower_msg.contains('bucket not found') || lower_msg.contains('nosuchbucket') {
return .bucket_not_found
}
if lower_msg.contains('not found') || lower_msg.contains('404') || lower_msg.contains('nosuchkey') {
return .object_not_found
}
if lower_msg.contains('invalid') && (lower_msg.contains('key') || lower_msg.contains('name')) {
return .invalid_object_key
}
if lower_msg.contains('quota') || lower_msg.contains('limit exceeded') {
return .quota_exceeded
}
if lower_msg.contains('config') {
return .invalid_config
}
return .unknown
}
// 从错误消息提取 HTTP 状态码
fn extract_http_status_from_message(msg string) int {
// 尝试查找 "http_status: xxx" 模式
if status_idx := msg.index('http_status:') {
start := status_idx + 12
mut end := start
for end < msg.len && (msg[end].is_digit() || msg[end] == ` `) {
end++
}
status_str := msg[start..end].trim_space()
return status_str.int()
}
// 尝试查找常见的 HTTP 状态码
status_codes := [400, 401, 403, 404, 409, 413, 429, 500, 502, 503, 504]
for code in status_codes {
if msg.contains(code.str()) {
return code
}
}
return 0
}
// ============================================================================
// 简化的重试函数
// ============================================================================
// 使用默认配置执行带重试的操作(返回 []u8)
pub fn retry_bytes(operation fn () ![]u8, provider string, op_name string) RetryResult[[]u8] {
executor := new_default_retry_executor()
return executor.execute_bytes(operation, provider, op_name)
}
// 使用默认配置执行带重试的操作(返回 StorageResult)
pub fn retry_storage_result(operation fn () !StorageResult, provider string, op_name string) RetryResult[StorageResult] {
executor := new_default_retry_executor()
return executor.execute_storage_result(operation, provider, op_name)
}
// 使用默认配置执行带重试的操作(返回 bool)
pub fn retry_bool(operation fn () !bool, provider string, op_name string) RetryResult[bool] {
executor := new_default_retry_executor()
return executor.execute_bool(operation, provider, op_name)
}
// 使用默认配置执行带重试的操作(无返回值)
pub fn retry_void(operation fn () !, provider string, op_name string) RetryResult[bool] {
executor := new_default_retry_executor()
return executor.execute_void(operation, provider, op_name)
}
// 使用默认配置执行带重试的操作(返回 string)
pub fn retry_string(operation fn () !string, provider string, op_name string) RetryResult[string] {
executor := new_default_retry_executor()
return executor.execute_string(operation, provider, op_name)
}
// 使用自定义配置执行带重试的操作(返回 []u8)
pub fn retry_bytes_with_config(operation fn () ![]u8, provider string, op_name string, config RetryConfig) RetryResult[[]u8] {
executor := new_retry_executor(config)
return executor.execute_bytes(operation, provider, op_name)
}
// 使用自定义配置执行带重试的操作(返回 StorageResult)
pub fn retry_storage_result_with_config(operation fn () !StorageResult, provider string, op_name string, config RetryConfig) RetryResult[StorageResult] {
executor := new_retry_executor(config)
return executor.execute_storage_result(operation, provider, op_name)
}
+26 -18
View File
@@ -208,10 +208,34 @@ pub fn (router ContextHybridRouter) match_static_route(method string, path strin
return none
}
// Context 版本的动态路径匹配
// Context 版本的动态路径匹配(保留旧版本兼容)
fn (mut router ContextHybridRouter) match_dynamic_route(method string, path string) ?ContextRouteMatch {
// 先检查缓存
cache_key := '${method}:${path}'
return router.match_dynamic_route_with_key(cache_key, method, path)
}
// Context 版本的主匹配函数(优化版:复用 cache key)
pub fn (mut router ContextHybridRouter) match_route(method string, path string) ?ContextRouteMatch {
// 优化:只拼接一次 cache key
cache_key := '${method}:${path}'
// 1. 先尝试静态路径匹配(最快)
if cache_key in router.static_routes {
return ContextRouteMatch{
handler: router.static_routes[cache_key]
params: map[string]string{}
path: path
base_path: ''
}
}
// 2. 再尝试动态路径匹配(复用 cache_key)
return router.match_dynamic_route_with_key(cache_key, method, path)
}
// Context 版本的动态路径匹配(优化版:接收预计算的 cache_key)
fn (mut router ContextHybridRouter) match_dynamic_route_with_key(cache_key string, method string, path string) ?ContextRouteMatch {
// 先检查缓存
if cached := router.cache.get(cache_key) {
return cached
}
@@ -240,22 +264,6 @@ fn (mut router ContextHybridRouter) match_dynamic_route(method string, path stri
return none
}
// Context 版本的主匹配函数
pub fn (mut router ContextHybridRouter) match_route(method string, path string) ?ContextRouteMatch {
// 1. 先尝试静态路径匹配(最快)
if static_handler := router.match_static_route(method, path) {
return ContextRouteMatch{
handler: static_handler
params: map[string]string{}
path: path
base_path: ''
}
}
// 2. 再尝试动态路径匹配
return router.match_dynamic_route(method, path)
}
// Context 版本的获取所有路由
pub fn (router ContextHybridRouter) get_all_routes() ([]string, []string) {
mut static_paths := []string{}
+958
View File
@@ -0,0 +1,958 @@
module hono
import io
import net.http
import net.urllib
import time
import crypto.sha256
import crypto.md5
import encoding.hex
// S3Storage S3 兼容存储提供者
pub struct S3Storage {
config S3Config
mut:
// 用于跟踪分片上传的内存存储
multipart_uploads map[string]S3MultipartUploadState
}
// S3 分片上传状态
struct S3MultipartUploadState {
mut:
bucket string
key string
upload_id string
content_type string
parts map[int]PartInfo
created_at i64
}
// 创建 S3 存储提供者
pub fn new_s3_storage(config S3Config) !S3Storage {
// 验证配置
validation := validate_s3_config(config)
if !validation.valid {
return error(validation.error_message)
}
return S3Storage{
config: config
multipart_uploads: map[string]S3MultipartUploadState{}
}
}
// ============================================================================
// AWS Signature V4 签名实现
// ============================================================================
// 签名请求所需的信息
struct S3SigningInfo {
method string
uri string
query_string string
headers map[string]string
signed_headers string
payload_hash string
timestamp time.Time
date_stamp string
datetime_stamp string
}
// 创建签名信息
fn (s S3Storage) create_signing_info(method string, uri string, query_params map[string]string, headers map[string]string, payload []u8) S3SigningInfo {
now := time.utc()
date_stamp := now.custom_format('YYYYMMDD')
datetime_stamp := now.custom_format('YYYYMMDD') + 'T' + now.custom_format('HHmmss') + 'Z'
// 计算 payload hash
payload_hash := s.hash_payload(payload)
// 构建查询字符串
query_string := s.build_canonical_query_string(query_params)
// 获取签名头列表
signed_headers := s.get_signed_headers(headers)
return S3SigningInfo{
method: method
uri: uri
query_string: query_string
headers: headers
signed_headers: signed_headers
payload_hash: payload_hash
timestamp: now
date_stamp: date_stamp
datetime_stamp: datetime_stamp
}
}
// 计算 payload 的 SHA256 哈希
fn (s S3Storage) hash_payload(payload []u8) string {
if payload.len == 0 {
// 空 payload 的哈希
return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
}
hash := sha256.sum(payload)
return hex.encode(hash)
}
// 构建规范查询字符串
fn (s S3Storage) build_canonical_query_string(params map[string]string) string {
if params.len == 0 {
return ''
}
// 获取排序后的键
mut keys := params.keys()
keys.sort()
mut parts := []string{}
for key in keys {
encoded_key := urllib.query_escape(key)
encoded_value := urllib.query_escape(params[key])
parts << '${encoded_key}=${encoded_value}'
}
return parts.join('&')
}
// 获取签名头列表(小写排序)
fn (s S3Storage) get_signed_headers(headers map[string]string) string {
mut header_names := []string{}
for key, _ in headers {
header_names << key.to_lower()
}
header_names.sort()
return header_names.join(';')
}
// 构建规范请求
fn (s S3Storage) build_canonical_request(info S3SigningInfo) string {
// 构建规范头
mut canonical_headers := ''
mut header_names := []string{}
for key, _ in info.headers {
header_names << key.to_lower()
}
header_names.sort()
for name in header_names {
// 查找原始键(不区分大小写)
for key, value in info.headers {
if key.to_lower() == name {
canonical_headers += '${name}:${value.trim_space()}\n'
break
}
}
}
return '${info.method}\n${info.uri}\n${info.query_string}\n${canonical_headers}\n${info.signed_headers}\n${info.payload_hash}'
}
// 构建待签名字符串
fn (s S3Storage) build_string_to_sign(info S3SigningInfo, canonical_request string) string {
algorithm := 'AWS4-HMAC-SHA256'
credential_scope := '${info.date_stamp}/${s.config.region}/s3/aws4_request'
// 计算规范请求的哈希
request_hash := sha256.sum(canonical_request.bytes())
hashed_request := hex.encode(request_hash)
return '${algorithm}\n${info.datetime_stamp}\n${credential_scope}\n${hashed_request}'
}
// 计算签名密钥
fn (s S3Storage) get_signing_key(date_stamp string) []u8 {
k_date := s3_hmac_sha256(('AWS4' + s.config.secret_key).bytes(), date_stamp.bytes())
k_region := s3_hmac_sha256(k_date, s.config.region.bytes())
k_service := s3_hmac_sha256(k_region, 's3'.bytes())
k_signing := s3_hmac_sha256(k_service, 'aws4_request'.bytes())
return k_signing
}
// 计算签名
fn (s S3Storage) calculate_signature(info S3SigningInfo, string_to_sign string) string {
signing_key := s.get_signing_key(info.date_stamp)
signature := s3_hmac_sha256(signing_key, string_to_sign.bytes())
return hex.encode(signature)
}
// HMAC-SHA256 实现
fn s3_hmac_sha256(key []u8, data []u8) []u8 {
block_size := 64
mut k := key.clone()
// 如果 key 长度大于 block_size,先进行 hash
if k.len > block_size {
k = sha256.sum(k)
}
// 如果 key 长度小于 block_size,用 0 填充
for k.len < block_size {
k << u8(0)
}
// 计算 inner 和 outer padding
mut i_pad := []u8{len: block_size}
mut o_pad := []u8{len: block_size}
for i in 0 .. block_size {
i_pad[i] = k[i] ^ u8(0x36)
o_pad[i] = k[i] ^ u8(0x5c)
}
// inner hash: SHA256(i_pad || data)
mut inner_data := i_pad.clone()
inner_data << data
inner_hash := sha256.sum(inner_data)
// outer hash: SHA256(o_pad || inner_hash)
mut outer_data := o_pad.clone()
outer_data << inner_hash
return sha256.sum(outer_data)
}
// 构建 Authorization 头
fn (s S3Storage) build_authorization_header(info S3SigningInfo, signature string) string {
algorithm := 'AWS4-HMAC-SHA256'
credential_scope := '${info.date_stamp}/${s.config.region}/s3/aws4_request'
credential := '${s.config.access_key}/${credential_scope}'
return '${algorithm} Credential=${credential}, SignedHeaders=${info.signed_headers}, Signature=${signature}'
}
// 签名请求并返回完整的请求头
pub fn (s S3Storage) sign_request(method string, uri string, query_params map[string]string, mut headers map[string]string, payload []u8) map[string]string {
// 添加必要的头
host := s.get_host('')
headers['Host'] = host
// 创建签名信息
info := s.create_signing_info(method, uri, query_params, headers, payload)
// 添加日期头
headers['x-amz-date'] = info.datetime_stamp
headers['x-amz-content-sha256'] = info.payload_hash
// 重新创建签名信息(包含新添加的头)
final_info := s.create_signing_info(method, uri, query_params, headers, payload)
// 构建规范请求
canonical_request := s.build_canonical_request(final_info)
// 构建待签名字符串
string_to_sign := s.build_string_to_sign(final_info, canonical_request)
// 计算签名
signature := s.calculate_signature(final_info, string_to_sign)
// 构建 Authorization 头
headers['Authorization'] = s.build_authorization_header(final_info, signature)
return headers
}
// ============================================================================
// HTTP 请求辅助方法
// ============================================================================
// 获取主机名
fn (s S3Storage) get_host(bucket string) string {
if s.config.path_style || bucket == '' {
return s.config.endpoint
}
return '${bucket}.${s.config.endpoint}'
}
// 获取完整 URL
fn (s S3Storage) get_url(bucket string, key string, query_params map[string]string) string {
scheme := if s.config.use_ssl { 'https' } else { 'http' }
host := s.get_host(bucket)
mut path := ''
if s.config.path_style {
if bucket != '' {
path = '/${bucket}'
}
if key != '' {
path += '/${key}'
}
} else {
if key != '' {
path = '/${key}'
}
}
if path == '' {
path = '/'
}
mut url := '${scheme}://${host}${path}'
if query_params.len > 0 {
query_string := s.build_canonical_query_string(query_params)
url += '?${query_string}'
}
return url
}
// 获取 URI 路径
fn (s S3Storage) get_uri_path(bucket string, key string) string {
if s.config.path_style {
mut path := ''
if bucket != '' {
path = '/${bucket}'
}
if key != '' {
path += '/${key}'
}
if path == '' {
return '/'
}
return path
} else {
if key != '' {
return '/${key}'
}
return '/'
}
}
// 执行 HTTP 请求
fn (s S3Storage) do_request(method http.Method, bucket string, key string, query_params map[string]string, extra_headers map[string]string, payload []u8) !http.Response {
url := s.get_url(bucket, key, query_params)
uri := s.get_uri_path(bucket, key)
// 准备请求头
mut headers := extra_headers.clone()
// 签名请求
signed_headers := s.sign_request(method.str(), uri, query_params, mut headers, payload)
// 构建 http.Header
mut http_header := http.Header{}
for k, v in signed_headers {
http_header.add_custom(k, v) or {}
}
// 执行请求
mut config := http.FetchConfig{
url: url
method: method
header: http_header
data: payload.bytestr()
}
response := http.fetch(config) or {
return error(new_timeout_error('s3', method.str()).msg())
}
return response
}
// 解析 S3 错误响应
fn (s S3Storage) parse_error_response(response http.Response, operation string) StorageError {
status := response.status_code
// 根据状态码判断错误类型
kind := match status {
403 { StorageErrorKind.access_denied }
404 { StorageErrorKind.object_not_found }
409 { StorageErrorKind.bucket_not_found }
429 { StorageErrorKind.rate_limited }
500, 502, 503, 504 { StorageErrorKind.service_unavailable }
else { StorageErrorKind.unknown }
}
// 尝试从响应体解析错误消息
message := if response.body.len > 0 {
s.extract_error_message(response.body)
} else {
'HTTP ${status}'
}
return new_storage_error_with_status(kind, message, 's3', operation, status)
}
// 从 XML 错误响应中提取错误消息
fn (s S3Storage) extract_error_message(body string) string {
// 简单的 XML 解析,提取 <Message> 标签内容
if message_start := body.index('<Message>') {
if message_end := body.index('</Message>') {
return body[message_start + 9..message_end]
}
}
return body
}
// 计算数据的 ETag (MD5)
fn (s S3Storage) calculate_s3_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
// ============================================================================
// StorageProvider 接口实现 - 基本操作
// ============================================================================
// 上传文件
pub fn (mut s S3Storage) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
mut headers := map[string]string{}
headers['Content-Type'] = content_type
headers['Content-Length'] = data.len.str()
response := s.do_request(.put, bucket, key, map[string]string{}, headers, data)!
if response.status_code != 200 {
return error(s.parse_error_response(response, 'upload').msg())
}
// 从响应头获取 ETag
etag := response.header.get_custom('ETag') or { s.calculate_s3_etag(data) }
return new_storage_result(key, etag, i64(data.len))
}
// 流式上传文件
pub fn (mut s S3Storage) upload_stream(bucket string, key string, mut reader io.Reader, size i64, content_type string) !StorageResult {
// 读取所有数据
mut data := []u8{}
mut buf := []u8{len: 8192}
for {
n := reader.read(mut buf) or { break }
if n == 0 {
break
}
data << buf[..n]
}
return s.upload(bucket, key, data, content_type)
}
// 下载文件
pub fn (s S3Storage) download(bucket string, key string) ![]u8 {
response := s.do_request(.get, bucket, key, map[string]string{}, map[string]string{},
[]u8{})!
if response.status_code == 404 {
return error(new_not_found_error('s3', bucket, key).msg())
}
if response.status_code != 200 {
return error(s.parse_error_response(response, 'download').msg())
}
return response.body.bytes()
}
// 流式下载文件
pub fn (s S3Storage) download_stream(bucket string, key string, mut writer io.Writer) !i64 {
data := s.download(bucket, key)!
written := writer.write(data) or {
return error(new_storage_error(.unknown, 'Failed to write to stream: ${err}', 's3',
'download_stream').msg())
}
return i64(written)
}
// 删除文件
pub fn (s S3Storage) delete(bucket string, key string) ! {
response := s.do_request(.delete, bucket, key, map[string]string{}, map[string]string{},
[]u8{})!
// S3 返回 204 表示成功删除
if response.status_code != 204 && response.status_code != 200 {
if response.status_code == 404 {
return error(new_not_found_error('s3', bucket, key).msg())
}
return error(s.parse_error_response(response, 'delete').msg())
}
}
// 检查文件是否存在
pub fn (s S3Storage) exists(bucket string, key string) !bool {
response := s.do_request(.head, bucket, key, map[string]string{}, map[string]string{},
[]u8{})!
if response.status_code == 200 {
return true
}
if response.status_code == 404 {
return false
}
return error(s.parse_error_response(response, 'exists').msg())
}
// ============================================================================
// StorageProvider 接口实现 - 元数据操作
// ============================================================================
// 获取文件元数据
pub fn (s S3Storage) head(bucket string, key string) !ObjectInfo {
response := s.do_request(.head, bucket, key, map[string]string{}, map[string]string{},
[]u8{})!
if response.status_code == 404 {
return error(new_not_found_error('s3', bucket, key).msg())
}
if response.status_code != 200 {
return error(s.parse_error_response(response, 'head').msg())
}
// 解析响应头
content_length := response.header.get_custom('Content-Length') or { '0' }
etag := response.header.get_custom('ETag') or { '' }
content_type := response.header.get_custom('Content-Type') or { 'application/octet-stream' }
last_modified_str := response.header.get_custom('Last-Modified') or { '' }
// 解析 Last-Modified 时间
last_modified := s3_parse_http_date(last_modified_str)
return new_object_info(key, content_length.i64(), etag, content_type, last_modified)
}
// 复制文件
pub fn (mut s S3Storage) copy(src_bucket string, src_key string, dst_bucket string, dst_key string) !StorageResult {
mut headers := map[string]string{}
// S3 复制源格式
copy_source := '/${src_bucket}/${src_key}'
headers['x-amz-copy-source'] = copy_source
response := s.do_request(.put, dst_bucket, dst_key, map[string]string{}, headers, []u8{})!
if response.status_code != 200 {
return error(s.parse_error_response(response, 'copy').msg())
}
// 从响应解析 ETag
etag := s.extract_copy_result_etag(response.body)
// 获取复制后文件的大小
head_info := s.head(dst_bucket, dst_key) or {
return new_storage_result(dst_key, etag, 0)
}
return new_storage_result(dst_key, etag, head_info.size)
}
// 从复制结果 XML 中提取 ETag
fn (s S3Storage) extract_copy_result_etag(body string) string {
if etag_start := body.index('<ETag>') {
if etag_end := body.index('</ETag>') {
return body[etag_start + 6..etag_end]
}
}
return ''
}
// ============================================================================
// StorageProvider 接口实现 - 列表操作
// ============================================================================
// 列出文件
pub fn (s S3Storage) list(bucket string, options ListOptions) !ListResult {
mut query_params := map[string]string{}
query_params['list-type'] = '2' // 使用 ListObjectsV2
if options.prefix != '' {
query_params['prefix'] = options.prefix
}
if options.delimiter != '' {
query_params['delimiter'] = options.delimiter
}
if options.max_keys > 0 {
query_params['max-keys'] = options.max_keys.str()
}
if options.start_after != '' {
query_params['start-after'] = options.start_after
}
response := s.do_request(.get, bucket, '', query_params, map[string]string{}, []u8{})!
if response.status_code == 404 {
return error(new_bucket_not_found_error('s3', bucket).msg())
}
if response.status_code != 200 {
return error(s.parse_error_response(response, 'list').msg())
}
// 解析 XML 响应
return s.parse_list_objects_response(response.body)
}
// 解析 ListObjectsV2 响应
fn (s S3Storage) parse_list_objects_response(body string) !ListResult {
mut objects := []ObjectInfo{}
mut common_prefixes := []string{}
mut is_truncated := false
mut next_marker := ''
// 解析 IsTruncated
if truncated_start := body.index('<IsTruncated>') {
if truncated_end := body.index('</IsTruncated>') {
truncated_str := body[truncated_start + 13..truncated_end]
is_truncated = truncated_str == 'true'
}
}
// 解析 NextContinuationToken
if token_start := body.index('<NextContinuationToken>') {
if token_end := body.index('</NextContinuationToken>') {
next_marker = body[token_start + 23..token_end]
}
}
// 解析 Contents
mut search_pos := 0
for {
content_start := body.index_after('<Contents>', search_pos) or { break }
content_end := body.index_after('</Contents>', content_start) or { break }
content_xml := body[content_start..content_end + 11]
obj := s.parse_object_from_xml(content_xml)
objects << obj
search_pos = content_end + 11
}
// 解析 CommonPrefixes
search_pos = 0
for {
prefix_start := body.index_after('<CommonPrefixes>', search_pos) or { break }
prefix_end := body.index_after('</CommonPrefixes>', prefix_start) or { break }
prefix_xml := body[prefix_start..prefix_end + 17]
if p_start := prefix_xml.index('<Prefix>') {
if p_end := prefix_xml.index('</Prefix>') {
common_prefixes << prefix_xml[p_start + 8..p_end]
}
}
search_pos = prefix_end + 17
}
return new_list_result(objects, common_prefixes, is_truncated, next_marker)
}
// 从 XML 解析单个对象信息
fn (s S3Storage) parse_object_from_xml(xml string) ObjectInfo {
mut key := ''
mut size := i64(0)
mut etag := ''
mut last_modified := i64(0)
if key_start := xml.index('<Key>') {
if key_end := xml.index('</Key>') {
key = xml[key_start + 5..key_end]
}
}
if size_start := xml.index('<Size>') {
if size_end := xml.index('</Size>') {
size = xml[size_start + 6..size_end].i64()
}
}
if etag_start := xml.index('<ETag>') {
if etag_end := xml.index('</ETag>') {
etag = xml[etag_start + 6..etag_end]
}
}
if lm_start := xml.index('<LastModified>') {
if lm_end := xml.index('</LastModified>') {
lm_str := xml[lm_start + 14..lm_end]
last_modified = s3_parse_iso8601_date(lm_str)
}
}
content_type := infer_content_type(key)
return new_object_info(key, size, etag, content_type, last_modified)
}
// ============================================================================
// StorageProvider 接口实现 - Bucket 操作
// ============================================================================
// 创建 bucket
pub fn (s S3Storage) create_bucket(bucket string) ! {
mut headers := map[string]string{}
// 如果不是 us-east-1,需要指定位置约束
mut payload := []u8{}
if s.config.region != 'us-east-1' {
location_constraint := '<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>${s.config.region}</LocationConstraint></CreateBucketConfiguration>'
payload = location_constraint.bytes()
headers['Content-Type'] = 'application/xml'
}
response := s.do_request(.put, bucket, '', map[string]string{}, headers, payload)!
// 200 或 409 (BucketAlreadyOwnedByYou) 都算成功
if response.status_code != 200 && response.status_code != 409 {
return error(s.parse_error_response(response, 'create_bucket').msg())
}
}
// 删除 bucket
pub fn (s S3Storage) delete_bucket(bucket string) ! {
response := s.do_request(.delete, bucket, '', map[string]string{}, map[string]string{},
[]u8{})!
if response.status_code != 204 && response.status_code != 200 {
if response.status_code == 404 {
return error(new_bucket_not_found_error('s3', bucket).msg())
}
return error(s.parse_error_response(response, 'delete_bucket').msg())
}
}
// 检查 bucket 是否存在
pub fn (s S3Storage) bucket_exists(bucket string) !bool {
response := s.do_request(.head, bucket, '', map[string]string{}, map[string]string{},
[]u8{})!
if response.status_code == 200 {
return true
}
if response.status_code == 404 {
return false
}
return error(s.parse_error_response(response, 'bucket_exists').msg())
}
// 获取提供者名称
pub fn (s S3Storage) provider_name() string {
return 's3'
}
// ============================================================================
// 辅助函数
// ============================================================================
// 解析 HTTP 日期格式
fn s3_parse_http_date(date_str string) i64 {
if date_str == '' {
return 0
}
// HTTP 日期格式: "Mon, 02 Jan 2006 15:04:05 GMT"
// 简化处理,返回当前时间
return time.now().unix()
}
// 解析 ISO8601 日期格式
fn s3_parse_iso8601_date(date_str string) i64 {
if date_str == '' {
return 0
}
// ISO8601 格式: "2006-01-02T15:04:05.000Z"
// 简化处理,返回当前时间
return time.now().unix()
}
// ============================================================================
// StorageProvider 接口实现 - 分片上传
// ============================================================================
// 初始化分片上传
pub fn (mut s S3Storage) init_multipart(bucket string, key string, content_type string) !string {
mut query_params := map[string]string{}
query_params['uploads'] = ''
mut headers := map[string]string{}
if content_type != '' {
headers['Content-Type'] = content_type
}
response := s.do_request(.post, bucket, key, query_params, headers, []u8{})!
if response.status_code != 200 {
return error(s.parse_error_response(response, 'init_multipart').msg())
}
// 从 XML 响应中提取 UploadId
upload_id := s.extract_upload_id(response.body)
if upload_id == '' {
return error(new_storage_error(.unknown, 'Failed to parse UploadId from response',
's3', 'init_multipart').msg())
}
// 记录上传状态
s.multipart_uploads[upload_id] = S3MultipartUploadState{
bucket: bucket
key: key
upload_id: upload_id
content_type: content_type
parts: map[int]PartInfo{}
created_at: time.now().unix()
}
return upload_id
}
// 从 InitiateMultipartUploadResult XML 中提取 UploadId
fn (s S3Storage) extract_upload_id(body string) string {
if id_start := body.index('<UploadId>') {
if id_end := body.index('</UploadId>') {
return body[id_start + 10..id_end]
}
}
return ''
}
// 上传分片
pub fn (mut s S3Storage) upload_part(bucket string, key string, upload_id string, part_number int, data []u8) !string {
mut query_params := map[string]string{}
query_params['partNumber'] = part_number.str()
query_params['uploadId'] = upload_id
mut headers := map[string]string{}
headers['Content-Length'] = data.len.str()
response := s.do_request(.put, bucket, key, query_params, headers, data)!
if response.status_code != 200 {
return error(s.parse_error_response(response, 'upload_part').msg())
}
// 从响应头获取 ETag
etag := response.header.get_custom('ETag') or { s.calculate_s3_etag(data) }
// 更新上传状态
if upload_id in s.multipart_uploads {
mut upload_state := s.multipart_uploads[upload_id]
upload_state.parts[part_number] = new_part_info(part_number, etag, i64(data.len))
s.multipart_uploads[upload_id] = upload_state
}
return etag
}
// 完成分片上传
pub fn (mut s S3Storage) complete_multipart(bucket string, key string, upload_id string, parts []PartInfo) !StorageResult {
mut query_params := map[string]string{}
query_params['uploadId'] = upload_id
// 构建 CompleteMultipartUpload XML
mut xml_parts := '<CompleteMultipartUpload>'
for part in parts {
xml_parts += '<Part>'
xml_parts += '<PartNumber>${part.part_number}</PartNumber>'
xml_parts += '<ETag>${part.etag}</ETag>'
xml_parts += '</Part>'
}
xml_parts += '</CompleteMultipartUpload>'
mut headers := map[string]string{}
headers['Content-Type'] = 'application/xml'
payload := xml_parts.bytes()
response := s.do_request(.post, bucket, key, query_params, headers, payload)!
if response.status_code != 200 {
return error(s.parse_error_response(response, 'complete_multipart').msg())
}
// 从响应解析 ETag
etag := s.extract_complete_multipart_etag(response.body)
// 计算总大小
mut total_size := i64(0)
for part in parts {
total_size += part.size
}
// 清理上传状态
s.multipart_uploads.delete(upload_id)
return new_storage_result(key, etag, total_size)
}
// 从 CompleteMultipartUploadResult XML 中提取 ETag
fn (s S3Storage) extract_complete_multipart_etag(body string) string {
if etag_start := body.index('<ETag>') {
if etag_end := body.index('</ETag>') {
return body[etag_start + 6..etag_end]
}
}
return ''
}
// 取消分片上传
pub fn (mut s S3Storage) abort_multipart(bucket string, key string, upload_id string) ! {
mut query_params := map[string]string{}
query_params['uploadId'] = upload_id
response := s.do_request(.delete, bucket, key, query_params, map[string]string{}, []u8{})!
if response.status_code != 204 && response.status_code != 200 {
return error(s.parse_error_response(response, 'abort_multipart').msg())
}
// 清理上传状态
s.multipart_uploads.delete(upload_id)
}
// ============================================================================
// StorageProvider 接口实现 - 预签名 URL
// ============================================================================
// 生成预签名 URL
pub fn (s S3Storage) presign_url(bucket string, key string, options PresignOptions) !string {
now := time.utc()
date_stamp := now.custom_format('YYYYMMDD')
datetime_stamp := now.custom_format('YYYYMMDD') + 'T' + now.custom_format('HHmmss') + 'Z'
// 构建查询参数
mut query_params := map[string]string{}
query_params['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256'
credential_scope := '${date_stamp}/${s.config.region}/s3/aws4_request'
query_params['X-Amz-Credential'] = '${s.config.access_key}/${credential_scope}'
query_params['X-Amz-Date'] = datetime_stamp
query_params['X-Amz-Expires'] = options.expires_in.str()
query_params['X-Amz-SignedHeaders'] = 'host'
// 构建 URI
uri := s.get_uri_path(bucket, key)
// 构建规范请求
host := s.get_host(bucket)
canonical_headers := 'host:${host}\n'
signed_headers := 'host'
payload_hash := 'UNSIGNED-PAYLOAD'
query_string := s.build_canonical_query_string(query_params)
canonical_request := '${options.method}\n${uri}\n${query_string}\n${canonical_headers}\n${signed_headers}\n${payload_hash}'
// 构建待签名字符串
request_hash := sha256.sum(canonical_request.bytes())
hashed_request := hex.encode(request_hash)
string_to_sign := 'AWS4-HMAC-SHA256\n${datetime_stamp}\n${credential_scope}\n${hashed_request}'
// 计算签名
signing_key := s.get_signing_key(date_stamp)
signature := s3_hmac_sha256(signing_key, string_to_sign.bytes())
signature_hex := hex.encode(signature)
// 添加签名到查询参数
query_params['X-Amz-Signature'] = signature_hex
// 构建最终 URL
return s.get_url(bucket, key, query_params)
}
+485
View File
@@ -0,0 +1,485 @@
module hono
import os
import x.json2
// 存储类型枚举
pub enum StorageType {
local
s3
aliyun_oss
tencent_cos
}
// 本地存储配置
pub struct LocalStorageConfig {
pub:
base_path string = './storage'
url_prefix string = '/files' // 用于生成访问URL
create_dirs bool = true
}
// S3 存储配置
pub struct S3Config {
pub:
endpoint string
access_key string
secret_key string
region string = 'us-east-1'
use_ssl bool = true
path_style bool // MinIO 通常需要 true
default_bucket string
}
// 阿里云 OSS 配置
pub struct AliyunOSSConfig {
pub:
endpoint string // oss-cn-hangzhou.aliyuncs.com
access_key_id string
access_key_secret string
internal_endpoint string // 内网端点(可选)
default_bucket string
}
// 腾讯云 COS 配置
pub struct TencentCOSConfig {
pub:
secret_id string
secret_key string
region string // ap-guangzhou
default_bucket string
}
// 统一存储配置
pub struct StorageConfig {
pub:
storage_type StorageType
local LocalStorageConfig
s3 S3Config
aliyun_oss AliyunOSSConfig
tencent_cos TencentCOSConfig
// 通用配置
retry_count int = 3
retry_delay_ms int = 1000
timeout_ms int = 30000
}
// 配置验证结果
pub struct ConfigValidationResult {
pub:
valid bool
missing_fields []string
error_message string
}
// 验证本地存储配置
pub fn validate_local_config(config LocalStorageConfig) ConfigValidationResult {
mut missing := []string{}
if config.base_path == '' {
missing << 'local.base_path'
}
if missing.len > 0 {
return ConfigValidationResult{
valid: false
missing_fields: missing
error_message: 'Missing required fields: ${missing.join(", ")}'
}
}
return ConfigValidationResult{
valid: true
missing_fields: []string{}
error_message: ''
}
}
// 验证 S3 配置
pub fn validate_s3_config(config S3Config) ConfigValidationResult {
mut missing := []string{}
if config.endpoint == '' {
missing << 's3.endpoint'
}
if config.access_key == '' {
missing << 's3.access_key'
}
if config.secret_key == '' {
missing << 's3.secret_key'
}
if config.default_bucket == '' {
missing << 's3.default_bucket'
}
if missing.len > 0 {
return ConfigValidationResult{
valid: false
missing_fields: missing
error_message: 'Missing required fields: ${missing.join(", ")}'
}
}
return ConfigValidationResult{
valid: true
missing_fields: []string{}
error_message: ''
}
}
// 验证阿里云 OSS 配置
pub fn validate_aliyun_oss_config(config AliyunOSSConfig) ConfigValidationResult {
mut missing := []string{}
if config.endpoint == '' {
missing << 'aliyun_oss.endpoint'
}
if config.access_key_id == '' {
missing << 'aliyun_oss.access_key_id'
}
if config.access_key_secret == '' {
missing << 'aliyun_oss.access_key_secret'
}
if config.default_bucket == '' {
missing << 'aliyun_oss.default_bucket'
}
if missing.len > 0 {
return ConfigValidationResult{
valid: false
missing_fields: missing
error_message: 'Missing required fields: ${missing.join(", ")}'
}
}
return ConfigValidationResult{
valid: true
missing_fields: []string{}
error_message: ''
}
}
// 验证腾讯云 COS 配置
pub fn validate_tencent_cos_config(config TencentCOSConfig) ConfigValidationResult {
mut missing := []string{}
if config.secret_id == '' {
missing << 'tencent_cos.secret_id'
}
if config.secret_key == '' {
missing << 'tencent_cos.secret_key'
}
if config.region == '' {
missing << 'tencent_cos.region'
}
if config.default_bucket == '' {
missing << 'tencent_cos.default_bucket'
}
if missing.len > 0 {
return ConfigValidationResult{
valid: false
missing_fields: missing
error_message: 'Missing required fields: ${missing.join(", ")}'
}
}
return ConfigValidationResult{
valid: true
missing_fields: []string{}
error_message: ''
}
}
// 验证存储配置
pub fn validate_storage_config(config StorageConfig) ConfigValidationResult {
match config.storage_type {
.local {
return validate_local_config(config.local)
}
.s3 {
return validate_s3_config(config.s3)
}
.aliyun_oss {
return validate_aliyun_oss_config(config.aliyun_oss)
}
.tencent_cos {
return validate_tencent_cos_config(config.tencent_cos)
}
}
}
// JSON 配置文件结构(用于解析)
struct JsonStorageConfig {
pub:
storage_type string @[json: 'storage_type']
local JsonLocalConfig @[json: 'local']
s3 JsonS3Config @[json: 's3']
aliyun_oss JsonAliyunOSSConfig @[json: 'aliyun_oss']
tencent_cos JsonTencentCOSConfig @[json: 'tencent_cos']
retry_count int = 3 @[json: 'retry_count']
retry_delay_ms int = 1000 @[json: 'retry_delay_ms']
timeout_ms int = 30000 @[json: 'timeout_ms']
}
struct JsonLocalConfig {
pub:
base_path string @[json: 'base_path']
url_prefix string @[json: 'url_prefix']
create_dirs bool = true @[json: 'create_dirs']
}
struct JsonS3Config {
pub:
endpoint string @[json: 'endpoint']
access_key string @[json: 'access_key']
secret_key string @[json: 'secret_key']
region string = 'us-east-1' @[json: 'region']
use_ssl bool = true @[json: 'use_ssl']
path_style bool @[json: 'path_style']
default_bucket string @[json: 'default_bucket']
}
struct JsonAliyunOSSConfig {
pub:
endpoint string @[json: 'endpoint']
access_key_id string @[json: 'access_key_id']
access_key_secret string @[json: 'access_key_secret']
internal_endpoint string @[json: 'internal_endpoint']
default_bucket string @[json: 'default_bucket']
}
struct JsonTencentCOSConfig {
pub:
secret_id string @[json: 'secret_id']
secret_key string @[json: 'secret_key']
region string @[json: 'region']
default_bucket string @[json: 'default_bucket']
}
// 从 JSON 文件加载存储配置
pub fn load_storage_config_from_file(file_path string) !StorageConfig {
if !os.exists(file_path) {
return error('Configuration file not found: ${file_path}')
}
content := os.read_file(file_path) or {
return error('Failed to read configuration file: ${err}')
}
return parse_storage_config_from_json(content)
}
// 从 JSON 字符串解析存储配置
pub fn parse_storage_config_from_json(json_str string) !StorageConfig {
json_config := json2.decode[JsonStorageConfig](json_str) or {
return error('Failed to parse JSON configuration: ${err}')
}
storage_type := parse_storage_type(json_config.storage_type) or {
return error('Invalid storage type: ${json_config.storage_type}')
}
config := StorageConfig{
storage_type: storage_type
local: LocalStorageConfig{
base_path: if json_config.local.base_path != '' {
json_config.local.base_path
} else {
'./storage'
}
url_prefix: if json_config.local.url_prefix != '' {
json_config.local.url_prefix
} else {
'/files'
}
create_dirs: json_config.local.create_dirs
}
s3: S3Config{
endpoint: json_config.s3.endpoint
access_key: json_config.s3.access_key
secret_key: json_config.s3.secret_key
region: if json_config.s3.region != '' { json_config.s3.region } else { 'us-east-1' }
use_ssl: json_config.s3.use_ssl
path_style: json_config.s3.path_style
default_bucket: json_config.s3.default_bucket
}
aliyun_oss: AliyunOSSConfig{
endpoint: json_config.aliyun_oss.endpoint
access_key_id: json_config.aliyun_oss.access_key_id
access_key_secret: json_config.aliyun_oss.access_key_secret
internal_endpoint: json_config.aliyun_oss.internal_endpoint
default_bucket: json_config.aliyun_oss.default_bucket
}
tencent_cos: TencentCOSConfig{
secret_id: json_config.tencent_cos.secret_id
secret_key: json_config.tencent_cos.secret_key
region: json_config.tencent_cos.region
default_bucket: json_config.tencent_cos.default_bucket
}
retry_count: if json_config.retry_count > 0 { json_config.retry_count } else { 3 }
retry_delay_ms: if json_config.retry_delay_ms > 0 {
json_config.retry_delay_ms
} else {
1000
}
timeout_ms: if json_config.timeout_ms > 0 { json_config.timeout_ms } else { 30000 }
}
// 验证配置
validation := validate_storage_config(config)
if !validation.valid {
return error(validation.error_message)
}
return config
}
// 解析存储类型字符串
fn parse_storage_type(type_str string) !StorageType {
match type_str.to_lower() {
'local' { return .local }
's3' { return .s3 }
'aliyun_oss', 'oss' { return .aliyun_oss }
'tencent_cos', 'cos' { return .tencent_cos }
else { return error('Unknown storage type: ${type_str}') }
}
}
// 从环境变量加载存储配置
pub fn load_storage_config_from_env() !StorageConfig {
storage_type_str := os.getenv('STORAGE_TYPE')
if storage_type_str == '' {
return error('Environment variable STORAGE_TYPE is not set')
}
storage_type := parse_storage_type(storage_type_str) or {
return error('Invalid STORAGE_TYPE: ${storage_type_str}')
}
config := StorageConfig{
storage_type: storage_type
local: LocalStorageConfig{
base_path: os.getenv_opt('STORAGE_LOCAL_BASE_PATH') or { './storage' }
url_prefix: os.getenv_opt('STORAGE_LOCAL_URL_PREFIX') or { '/files' }
create_dirs: os.getenv_opt('STORAGE_LOCAL_CREATE_DIRS') or { 'true' } == 'true'
}
s3: S3Config{
endpoint: os.getenv('STORAGE_S3_ENDPOINT')
access_key: os.getenv('STORAGE_S3_ACCESS_KEY')
secret_key: os.getenv('STORAGE_S3_SECRET_KEY')
region: os.getenv_opt('STORAGE_S3_REGION') or { 'us-east-1' }
use_ssl: os.getenv_opt('STORAGE_S3_USE_SSL') or { 'true' } == 'true'
path_style: os.getenv_opt('STORAGE_S3_PATH_STYLE') or { 'false' } == 'true'
default_bucket: os.getenv('STORAGE_S3_DEFAULT_BUCKET')
}
aliyun_oss: AliyunOSSConfig{
endpoint: os.getenv('STORAGE_OSS_ENDPOINT')
access_key_id: os.getenv('STORAGE_OSS_ACCESS_KEY_ID')
access_key_secret: os.getenv('STORAGE_OSS_ACCESS_KEY_SECRET')
internal_endpoint: os.getenv('STORAGE_OSS_INTERNAL_ENDPOINT')
default_bucket: os.getenv('STORAGE_OSS_DEFAULT_BUCKET')
}
tencent_cos: TencentCOSConfig{
secret_id: os.getenv('STORAGE_COS_SECRET_ID')
secret_key: os.getenv('STORAGE_COS_SECRET_KEY')
region: os.getenv('STORAGE_COS_REGION')
default_bucket: os.getenv('STORAGE_COS_DEFAULT_BUCKET')
}
retry_count: os.getenv_opt('STORAGE_RETRY_COUNT') or { '3' }.int()
retry_delay_ms: os.getenv_opt('STORAGE_RETRY_DELAY_MS') or { '1000' }.int()
timeout_ms: os.getenv_opt('STORAGE_TIMEOUT_MS') or { '30000' }.int()
}
// 验证配置
validation := validate_storage_config(config)
if !validation.valid {
return error(validation.error_message)
}
return config
}
// 创建默认本地存储配置
pub fn new_local_storage_config(base_path string) StorageConfig {
return StorageConfig{
storage_type: .local
local: LocalStorageConfig{
base_path: base_path
url_prefix: '/files'
create_dirs: true
}
}
}
// 创建 S3 存储配置
pub fn new_s3_storage_config(endpoint string, access_key string, secret_key string, bucket string) StorageConfig {
return StorageConfig{
storage_type: .s3
s3: S3Config{
endpoint: endpoint
access_key: access_key
secret_key: secret_key
region: 'us-east-1'
use_ssl: true
path_style: false
default_bucket: bucket
}
}
}
// 创建 MinIO 存储配置(S3 兼容,使用 path_style)
pub fn new_minio_storage_config(endpoint string, access_key string, secret_key string, bucket string) StorageConfig {
return StorageConfig{
storage_type: .s3
s3: S3Config{
endpoint: endpoint
access_key: access_key
secret_key: secret_key
region: 'us-east-1'
use_ssl: false
path_style: true
default_bucket: bucket
}
}
}
// 创建阿里云 OSS 存储配置
pub fn new_aliyun_oss_storage_config(endpoint string, access_key_id string, access_key_secret string, bucket string) StorageConfig {
return StorageConfig{
storage_type: .aliyun_oss
aliyun_oss: AliyunOSSConfig{
endpoint: endpoint
access_key_id: access_key_id
access_key_secret: access_key_secret
internal_endpoint: ''
default_bucket: bucket
}
}
}
// 创建腾讯云 COS 存储配置
pub fn new_tencent_cos_storage_config(secret_id string, secret_key string, region string, bucket string) StorageConfig {
return StorageConfig{
storage_type: .tencent_cos
tencent_cos: TencentCOSConfig{
secret_id: secret_id
secret_key: secret_key
region: region
default_bucket: bucket
}
}
}
// 获取存储类型名称
pub fn (t StorageType) str() string {
match t {
.local { return 'local' }
.s3 { return 's3' }
.aliyun_oss { return 'aliyun_oss' }
.tencent_cos { return 'tencent_cos' }
}
}
+500
View File
@@ -0,0 +1,500 @@
module hono
import time
// 存储错误类型枚举
pub enum StorageErrorKind {
// 可重试错误 - 网络和临时性问题
network_timeout
service_unavailable
rate_limited
connection_reset
temporary_failure
// 不可重试错误 - 配置和权限问题
invalid_credentials
access_denied
bucket_not_found
object_not_found
invalid_object_key
quota_exceeded
invalid_config
bucket_not_empty
bucket_already_exists
object_already_exists
invalid_range
checksum_mismatch
// 其他错误
unknown
}
// 存储错误结构
pub struct StorageError {
pub:
kind StorageErrorKind
message string
provider string
operation string
http_status int
retry_count int
details map[string]string
pub mut:
retry_history []RetryAttempt
}
// 重试尝试记录
pub struct RetryAttempt {
pub:
attempt_number int
timestamp i64
error_message string
delay_ms int
}
// 判断错误是否可重试
pub fn (e StorageError) is_retryable() bool {
return e.kind in [
.network_timeout,
.service_unavailable,
.rate_limited,
.connection_reset,
.temporary_failure,
]
}
// 获取建议的重试延迟(毫秒)
pub fn (e StorageError) suggested_retry_delay() int {
base_delay := match e.kind {
.rate_limited { 5000 } // 限流错误需要更长等待
.service_unavailable { 2000 }
.network_timeout { 1000 }
.connection_reset { 500 }
.temporary_failure { 1000 }
else { 0 }
}
return base_delay
}
// 获取错误类别描述
pub fn (e StorageError) category() string {
if e.is_retryable() {
return 'retryable'
}
return 'non_retryable'
}
// 实现 IError 接口
pub fn (e StorageError) msg() string {
return '${e.provider}/${e.operation}: ${e.message} (kind: ${e.kind}, http_status: ${e.http_status})'
}
pub fn (e StorageError) code() int {
return e.http_status
}
// 添加重试记录
pub fn (mut e StorageError) add_retry_attempt(attempt_number int, error_message string, delay_ms int) {
e.retry_history << RetryAttempt{
attempt_number: attempt_number
timestamp: time.now().unix()
error_message: error_message
delay_ms: delay_ms
}
}
// 获取重试历史摘要
pub fn (e StorageError) retry_summary() string {
if e.retry_history.len == 0 {
return 'No retry attempts'
}
mut summary := 'Retry attempts: ${e.retry_history.len}\n'
for attempt in e.retry_history {
summary += ' Attempt ${attempt.attempt_number}: ${attempt.error_message} (delay: ${attempt.delay_ms}ms)\n'
}
return summary
}
// 创建存储错误的辅助函数
pub fn new_storage_error(kind StorageErrorKind, message string, provider string, operation string) StorageError {
return StorageError{
kind: kind
message: message
provider: provider
operation: operation
http_status: 0
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建带 HTTP 状态码的存储错误
pub fn new_storage_error_with_status(kind StorageErrorKind, message string, provider string, operation string, http_status int) StorageError {
return StorageError{
kind: kind
message: message
provider: provider
operation: operation
http_status: http_status
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建带详情的存储错误
pub fn new_storage_error_with_details(kind StorageErrorKind, message string, provider string, operation string, http_status int, details map[string]string) StorageError {
return StorageError{
kind: kind
message: message
provider: provider
operation: operation
http_status: http_status
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// 创建配置错误
pub fn new_config_error(message string, field string) StorageError {
mut details := map[string]string{}
details['field'] = field
return StorageError{
kind: .invalid_config
message: message
provider: 'config'
operation: 'validate'
http_status: 0
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// 创建对象未找到错误
pub fn new_not_found_error(provider string, bucket string, key string) StorageError {
mut details := map[string]string{}
details['bucket'] = bucket
details['key'] = key
return StorageError{
kind: .object_not_found
message: 'Object not found: ${bucket}/${key}'
provider: provider
operation: 'get'
http_status: 404
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// 创建存储桶未找到错误
pub fn new_bucket_not_found_error(provider string, bucket string) StorageError {
mut details := map[string]string{}
details['bucket'] = bucket
return StorageError{
kind: .bucket_not_found
message: 'Bucket not found: ${bucket}'
provider: provider
operation: 'bucket'
http_status: 404
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// 创建访问拒绝错误
pub fn new_access_denied_error(provider string, operation string, resource string) StorageError {
mut details := map[string]string{}
details['resource'] = resource
return StorageError{
kind: .access_denied
message: 'Access denied to resource: ${resource}'
provider: provider
operation: operation
http_status: 403
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// 创建网络超时错误
pub fn new_timeout_error(provider string, operation string) StorageError {
return StorageError{
kind: .network_timeout
message: 'Network timeout during ${operation}'
provider: provider
operation: operation
http_status: 0
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建服务不可用错误
pub fn new_service_unavailable_error(provider string, operation string, message string) StorageError {
return StorageError{
kind: .service_unavailable
message: message
provider: provider
operation: operation
http_status: 503
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建限流错误
pub fn new_rate_limited_error(provider string, operation string) StorageError {
return StorageError{
kind: .rate_limited
message: 'Rate limited during ${operation}'
provider: provider
operation: operation
http_status: 429
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建连接重置错误
pub fn new_connection_reset_error(provider string, operation string) StorageError {
return StorageError{
kind: .connection_reset
message: 'Connection reset during ${operation}'
provider: provider
operation: operation
http_status: 0
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建凭据无效错误
pub fn new_invalid_credentials_error(provider string, operation string) StorageError {
return StorageError{
kind: .invalid_credentials
message: 'Invalid credentials for ${provider}'
provider: provider
operation: operation
http_status: 401
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// 创建配额超限错误
pub fn new_quota_exceeded_error(provider string, operation string, resource string) StorageError {
mut details := map[string]string{}
details['resource'] = resource
return StorageError{
kind: .quota_exceeded
message: 'Quota exceeded for ${resource}'
provider: provider
operation: operation
http_status: 413
retry_count: 0
details: details
retry_history: []RetryAttempt{}
}
}
// ============================================================================
// HTTP 状态码到错误类型的映射
// ============================================================================
// 从 HTTP 状态码映射到错误类型
pub fn error_kind_from_http_status(status int) StorageErrorKind {
return match status {
400 { StorageErrorKind.invalid_object_key }
401 { StorageErrorKind.invalid_credentials }
403 { StorageErrorKind.access_denied }
404 { StorageErrorKind.object_not_found }
409 { StorageErrorKind.bucket_already_exists }
413 { StorageErrorKind.quota_exceeded }
416 { StorageErrorKind.invalid_range }
429 { StorageErrorKind.rate_limited }
500 { StorageErrorKind.service_unavailable }
502 { StorageErrorKind.service_unavailable }
503 { StorageErrorKind.service_unavailable }
504 { StorageErrorKind.network_timeout }
else { StorageErrorKind.unknown }
}
}
// ============================================================================
// 提供者特定的错误映射
// ============================================================================
// S3 错误代码映射
pub fn map_s3_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'AccountProblem' { StorageErrorKind.access_denied }
'AllAccessDisabled' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketAlreadyOwnedByYou' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'CredentialsNotSupported' { StorageErrorKind.invalid_credentials }
'ExpiredToken' { StorageErrorKind.invalid_credentials }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'InvalidSecurity' { StorageErrorKind.invalid_credentials }
'InvalidToken' { StorageErrorKind.invalid_credentials }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'SlowDown' { StorageErrorKind.rate_limited }
'TemporaryRedirect' { StorageErrorKind.temporary_failure }
'TokenRefreshRequired' { StorageErrorKind.invalid_credentials }
else { error_kind_from_http_status(http_status) }
}
}
// 阿里云 OSS 错误代码映射
pub fn map_aliyun_oss_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'EntityTooLarge' { StorageErrorKind.quota_exceeded }
'EntityTooSmall' { StorageErrorKind.invalid_object_key }
'FileGroupTooLarge' { StorageErrorKind.quota_exceeded }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidArgument' { StorageErrorKind.invalid_object_key }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidDigest' { StorageErrorKind.checksum_mismatch }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'TooManyBuckets' { StorageErrorKind.quota_exceeded }
else { error_kind_from_http_status(http_status) }
}
}
// 腾讯云 COS 错误代码映射
pub fn map_tencent_cos_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketAlreadyOwnedByYou' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'EntityTooLarge' { StorageErrorKind.quota_exceeded }
'EntityTooSmall' { StorageErrorKind.invalid_object_key }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidArgument' { StorageErrorKind.invalid_object_key }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidDigest' { StorageErrorKind.checksum_mismatch }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'SlowDown' { StorageErrorKind.rate_limited }
'TooManyBuckets' { StorageErrorKind.quota_exceeded }
else { error_kind_from_http_status(http_status) }
}
}
// ============================================================================
// 从错误消息解析错误代码
// ============================================================================
// 从 XML 错误响应中提取错误代码
pub fn extract_error_code_from_xml(body string) string {
if code_start := body.index('<Code>') {
if code_end := body.index('</Code>') {
return body[code_start + 6..code_end]
}
}
return ''
}
// 从 XML 错误响应中提取错误消息
pub fn extract_error_message_from_xml(body string) string {
if message_start := body.index('<Message>') {
if message_end := body.index('</Message>') {
return body[message_start + 9..message_end]
}
}
return ''
}
// ============================================================================
// 创建提供者特定的错误
// ============================================================================
// 从 S3 响应创建错误
pub fn new_s3_error_from_response(body string, http_status int, operation string) StorageError {
error_code := extract_error_code_from_xml(body)
error_message := extract_error_message_from_xml(body)
kind := map_s3_error_code(error_code, http_status)
message := if error_message != '' { error_message } else { 'HTTP ${http_status}' }
mut details := map[string]string{}
if error_code != '' {
details['error_code'] = error_code
}
return new_storage_error_with_details(kind, message, 's3', operation, http_status, details)
}
// 从阿里云 OSS 响应创建错误
pub fn new_aliyun_oss_error_from_response(body string, http_status int, operation string) StorageError {
error_code := extract_error_code_from_xml(body)
error_message := extract_error_message_from_xml(body)
kind := map_aliyun_oss_error_code(error_code, http_status)
message := if error_message != '' { error_message } else { 'HTTP ${http_status}' }
mut details := map[string]string{}
if error_code != '' {
details['error_code'] = error_code
}
return new_storage_error_with_details(kind, message, 'aliyun_oss', operation, http_status, details)
}
// 从腾讯云 COS 响应创建错误
pub fn new_tencent_cos_error_from_response(body string, http_status int, operation string) StorageError {
error_code := extract_error_code_from_xml(body)
error_message := extract_error_message_from_xml(body)
kind := map_tencent_cos_error_code(error_code, http_status)
message := if error_message != '' { error_message } else { 'HTTP ${http_status}' }
mut details := map[string]string{}
if error_code != '' {
details['error_code'] = error_code
}
return new_storage_error_with_details(kind, message, 'tencent_cos', operation, http_status, details)
}
+164
View File
@@ -0,0 +1,164 @@
module hono
import io
// 存储操作结果
pub struct StorageResult {
pub:
success bool
object_key string
etag string
size i64
error_msg string
}
// 文件对象信息
pub struct ObjectInfo {
pub:
key string
size i64
etag string
content_type string
last_modified i64
metadata map[string]string
}
// 列表选项
pub struct ListOptions {
pub:
prefix string
delimiter string
max_keys int = 1000
start_after string
}
// 列表结果
pub struct ListResult {
pub:
objects []ObjectInfo
common_prefixes []string
is_truncated bool
next_marker string
}
// 预签名URL选项
pub struct PresignOptions {
pub:
expires_in int = 3600 // 秒
method string = 'GET'
content_type string
}
// 分片信息
pub struct PartInfo {
pub:
part_number int
etag string
size i64
}
// 统一存储接口
pub interface StorageProvider {
mut:
// 基本操作
upload(bucket string, key string, data []u8, content_type string) !StorageResult
upload_stream(bucket string, key string, mut reader io.Reader, size i64, content_type string) !StorageResult
download(bucket string, key string) ![]u8
download_stream(bucket string, key string, mut writer io.Writer) !i64
delete(bucket string, key string) !
exists(bucket string, key string) !bool
// 元数据操作
head(bucket string, key string) !ObjectInfo
copy(src_bucket string, src_key string, dst_bucket string, dst_key string) !StorageResult
// 列表操作
list(bucket string, options ListOptions) !ListResult
// 预签名URL
presign_url(bucket string, key string, options PresignOptions) !string
// 分片上传
init_multipart(bucket string, key string, content_type string) !string // 返回 upload_id
upload_part(bucket string, key string, upload_id string, part_number int, data []u8) !string // 返回 etag
complete_multipart(bucket string, key string, upload_id string, parts []PartInfo) !StorageResult
abort_multipart(bucket string, key string, upload_id string) !
// Bucket 操作
create_bucket(bucket string) !
delete_bucket(bucket string) !
bucket_exists(bucket string) !bool
// 获取提供者名称
provider_name() string
}
// 创建成功的存储结果
pub fn new_storage_result(object_key string, etag string, size i64) StorageResult {
return StorageResult{
success: true
object_key: object_key
etag: etag
size: size
error_msg: ''
}
}
// 创建失败的存储结果
pub fn new_storage_error_result(error_msg string) StorageResult {
return StorageResult{
success: false
object_key: ''
etag: ''
size: 0
error_msg: error_msg
}
}
// 创建对象信息
pub fn new_object_info(key string, size i64, etag string, content_type string, last_modified i64) ObjectInfo {
return ObjectInfo{
key: key
size: size
etag: etag
content_type: content_type
last_modified: last_modified
metadata: map[string]string{}
}
}
// 创建带元数据的对象信息
pub fn new_object_info_with_metadata(key string, size i64, etag string, content_type string, last_modified i64, metadata map[string]string) ObjectInfo {
return ObjectInfo{
key: key
size: size
etag: etag
content_type: content_type
last_modified: last_modified
metadata: metadata
}
}
// 创建空的列表结果
pub fn new_empty_list_result() ListResult {
return ListResult{
objects: []ObjectInfo{}
common_prefixes: []string{}
is_truncated: false
next_marker: ''
}
}
// 创建列表结果
pub fn new_list_result(objects []ObjectInfo, common_prefixes []string, is_truncated bool, next_marker string) ListResult {
return ListResult{
objects: objects
common_prefixes: common_prefixes
is_truncated: is_truncated
next_marker: next_marker
}
}
// 创建分片信息
pub fn new_part_info(part_number int, etag string, size i64) PartInfo {
return PartInfo{
part_number: part_number
etag: etag
size: size
}
}
+982
View File
@@ -0,0 +1,982 @@
// streaming.v - SSE Streaming Helper for v-hono
// Provides Server-Sent Events (SSE) and streaming response support
// Reference: Hono.js Streaming Helper API
module hono
import net.http
import time
import picohttpparser
import usockets
// C function declaration for direct socket writes
fn C.send(sockfd int, buf voidptr, len int, flags int) int
// ============================================================================
// SSE Event Structure
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Used with stream_sse() to send formatted SSE events to clients
pub struct SSEEvent {
pub:
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamWriter Interface
// ============================================================================
// StreamWriter - Abstract interface for stream writing
// Allows different server backends (picoev, uSockets) to implement their own writers
pub interface StreamWriter {
// Check if the connection is still active (immutable)
is_connected() bool
mut:
// Write raw bytes to the stream
write(data []u8) !
// Write string to the stream
write_string(data string) !
// Flush the buffer to ensure data is sent
flush() !
// Close the stream connection
close() !
}
// ============================================================================
// StreamContext Structure
// ============================================================================
// StreamContext - Context provided to streaming callbacks
// Provides methods for writing data, SSE events, and managing the stream
@[heap]
pub struct StreamContext {
mut:
writer &StreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
pub fn StreamContext.new(writer &StreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
pub fn (mut ctx StreamContext) write(data []u8) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write(data)!
}
// write_string - Write a string to the stream
pub fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// writeln - Write a string followed by a newline character
pub fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
pub fn (mut ctx StreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
// sleep - Pause execution for the specified milliseconds
pub fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
// pipe - Pipe data from a byte array to the stream
pub fn (mut ctx StreamContext) pipe(data []u8) ! {
ctx.write(data)!
}
// on_abort - Register a callback to be called when the client aborts the connection
pub fn (mut ctx StreamContext) on_abort(callback fn ()) {
ctx.abort_fn = callback
}
// close - Close the stream
pub fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
pub fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// trigger_abort - Internal method to trigger the abort callback
// Called by server backends when client disconnects
pub fn (mut ctx StreamContext) trigger_abort() {
if abort_callback := ctx.abort_fn {
abort_callback()
}
ctx.is_closed = true
}
// ============================================================================
// Picoev StreamWriter Implementation
// ============================================================================
// PicoevStreamWriter - StreamWriter implementation for picoev server
// Supports chunked transfer encoding for streaming responses
// Uses direct socket fd writes for immediate data delivery (true streaming)
@[heap]
pub struct PicoevStreamWriter {
mut:
fd int // Socket file descriptor for direct writes
connected bool
}
// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
// Extracts the fd from picohttpparser.Response for direct socket writes
pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
return &PicoevStreamWriter{
fd: res.fd
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
// Writes directly to socket fd for immediate delivery
pub fn (mut w PicoevStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data)!
w.write_to_fd('\r\n'.bytes())!
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w PicoevStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data.bytes())!
w.write_to_fd('\r\n'.bytes())!
}
// write_to_fd - Write bytes directly to the socket file descriptor
// This ensures immediate delivery without buffering
@[inline]
fn (mut w PicoevStreamWriter) write_to_fd(data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(w.fd, ptr, remaining, 0)
if written < 0 {
w.connected = false
return error('Failed to write to socket')
}
if written == 0 {
w.connected = false
return error('Connection closed by peer')
}
total_written += written
}
}
// flush - Flush the buffer (direct fd writes are already unbuffered)
pub fn (mut w PicoevStreamWriter) flush() ! {
// Direct fd writes are unbuffered, no explicit flush needed
// But we can use TCP_NODELAY to disable Nagle's algorithm if needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w PicoevStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.write_to_fd('0\r\n\r\n'.bytes()) or {}
w.connected = false
// Note: Don't close the fd here, picoev manages the connection lifecycle
}
}
// is_connected - Check if the connection is still active
pub fn (w PicoevStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// uSockets StreamWriter Implementation
// ============================================================================
// UsocketsStreamWriter - StreamWriter implementation for uSockets server
// Supports chunked transfer encoding for streaming responses
@[heap]
pub struct UsocketsStreamWriter {
mut:
socket usockets.Socket
connected bool
}
// UsocketsStreamWriter.new - Create a new UsocketsStreamWriter with the given socket
pub fn UsocketsStreamWriter.new(socket usockets.Socket) &UsocketsStreamWriter {
return &UsocketsStreamWriter{
socket: socket
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
pub fn (mut w UsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w UsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
// flush - Flush the buffer (uSockets handles this automatically)
pub fn (mut w UsocketsStreamWriter) flush() ! {
// uSockets automatically flushes data
// No explicit flush needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w UsocketsStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
// is_connected - Check if the connection is still active
pub fn (w UsocketsStreamWriter) is_connected() bool {
return w.connected
}
// mark_disconnected - Mark the connection as disconnected
// Called by server backends when client disconnects
pub fn (mut w UsocketsStreamWriter) mark_disconnected() {
w.connected = false
}
// ============================================================================
// Helper Functions for Chunked Encoding
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
// This is used for HTTP chunked transfer encoding
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
// Returns the full chunk in chunked transfer encoding format
pub fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
pub fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
pub fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Streaming Helper Functions
// ============================================================================
// StreamCallback - Callback function type for streaming
// The callback receives a mutable StreamContext and can write data to the stream
pub type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
// Called when an error occurs during streaming
pub type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// StreamResponse - Response type for streaming operations
// Contains the headers that should be sent before streaming begins
pub struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// stream - Basic binary streaming function
// Sets Transfer-Encoding: chunked and executes the callback with a StreamContext
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers
//
// Example:
// stream(writer, fn (mut stream) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
pub fn stream(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// stream_with_headers - Stream with custom initial headers
// Allows setting additional headers before streaming begins
pub fn stream_with_headers(mut writer StreamWriter, headers map[string]string, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Merge headers with required streaming headers
mut response_headers := headers.clone()
response_headers['Transfer-Encoding'] = 'chunked'
if 'Connection' !in response_headers {
response_headers['Connection'] = 'keep-alive'
}
return StreamResponse{
status_code: 200
headers: response_headers
is_stream: true
}
}
// get_stream_headers - Get the headers required for basic streaming
// Returns a map of headers that should be set for chunked transfer encoding
pub fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// write_stream_headers - Write HTTP headers for streaming response
// This is a helper function to write the initial HTTP response line and headers
// before starting chunked data transfer
//
// Parameters:
// writer: The StreamWriter to write headers to
// status_code: HTTP status code (default 200)
// headers: Map of header name to value
pub fn write_stream_headers(mut writer StreamWriter, status_code int, headers map[string]string) ! {
// Build HTTP response line
status_text := get_status_text(status_code)
mut response := 'HTTP/1.1 ${status_code} ${status_text}\r\n'
// Add headers
for key, value in headers {
response += '${key}: ${value}\r\n'
}
// Add required streaming headers if not present
if 'Transfer-Encoding' !in headers {
response += 'Transfer-Encoding: chunked\r\n'
}
if 'Connection' !in headers {
response += 'Connection: keep-alive\r\n'
}
// End headers section
response += '\r\n'
// Write headers directly (not chunked)
// Note: Headers are written raw, not using chunked encoding
// The chunked encoding starts after the headers
writer.write_string(response)!
}
// stream_text - Text streaming function
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers, then executes the callback
// with a StreamContext for text streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for text streaming
//
// Example:
// stream_text(writer, fn (mut stream) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
pub fn stream_text(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamText Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for text streaming
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// get_stream_text_headers - Get the headers required for text streaming
// Returns a map of headers that should be set for text streaming with chunked transfer encoding
pub fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// stream_sse - Server-Sent Events streaming function
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers, then executes the callback
// with a StreamContext for SSE streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for SSE streaming
//
// Example:
// stream_sse(writer, fn (mut stream) ! {
// stream.write_sse(SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
pub fn stream_sse(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamSSE Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for SSE streaming
// Requirements 3.1, 3.2, 3.3:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
is_stream: true
}
}
// get_stream_sse_headers - Get the headers required for SSE streaming
// Returns a map of headers that should be set for Server-Sent Events streaming
pub fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Context-Based Streaming Functions
// ============================================================================
// These functions are designed to be used in route handlers and integrate
// with the picoev and uSockets server backends.
// StreamType - Type of streaming response
pub enum StreamType {
basic // Basic binary streaming
text // Text streaming with text/plain content type
sse // Server-Sent Events streaming
}
// StreamConfig - Configuration stored in Context for streaming responses
// This is used internally by the server backends to detect and handle streaming
pub struct StreamConfig {
pub:
stream_type StreamType
callback StreamCallback = unsafe { nil }
error_handler ?StreamErrorHandler
}
// Global storage for stream callbacks (workaround for V's limitations with storing functions in maps)
// Key is a unique identifier stored in Context.store['_stream_id']
// Using shared map for thread-safe access
struct StreamCallbackStorage {
mut:
callbacks shared map[string]StreamConfig
}
// Module-level storage instance
const stream_storage = &StreamCallbackStorage{}
// store_stream_config - Store a stream configuration
fn store_stream_config(id string, config StreamConfig) {
lock stream_storage.callbacks {
stream_storage.callbacks[id] = config
}
}
// retrieve_stream_config - Retrieve a stream configuration by ID
fn retrieve_stream_config(id string) ?StreamConfig {
rlock stream_storage.callbacks {
if id in stream_storage.callbacks {
return stream_storage.callbacks[id]
}
}
return none
}
// remove_stream_config - Remove a stream configuration by ID
fn remove_stream_config(id string) {
lock stream_storage.callbacks {
stream_storage.callbacks.delete(id)
}
}
// generate_stream_id - Generate a unique stream ID
fn generate_stream_id() string {
return '${time.now().unix_milli()}_${rand_int()}'
}
// rand_int - Simple random integer generator
fn rand_int() int {
return int(time.now().unix_nano() % 1000000)
}
// c_stream - Basic binary streaming function for use in route handlers
// Sets Transfer-Encoding: chunked and stores the callback for execution by the server backend
//
// Parameters:
// c: The request Context
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream', fn (mut c hono.Context) http.Response {
// return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
// })
pub fn c_stream(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .basic
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'basic'
// Set streaming headers
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['Connection'] = 'keep-alive'
// Return a marker response (body will be ignored, streaming will be handled by server)
mut headers := http.new_header()
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_text - Text streaming function for use in route handlers
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream-text', fn (mut c hono.Context) http.Response {
// return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
// })
pub fn c_stream_text(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .text
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'text'
// Set streaming headers
c.headers['Content-Type'] = 'text/plain; charset=utf-8'
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['X-Content-Type-Options'] = 'nosniff'
c.headers['Connection'] = 'keep-alive'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/plain; charset=utf-8') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('X-Content-Type-Options', 'nosniff') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_sse - Server-Sent Events streaming function for use in route handlers
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/sse', fn (mut c hono.Context) http.Response {
// return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_sse(hono.SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(hono.SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
// })
pub fn c_stream_sse(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .sse
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'sse'
// Set SSE headers (Requirements 3.1, 3.2, 3.3)
c.headers['Content-Type'] = 'text/event-stream'
c.headers['Cache-Control'] = 'no-cache'
c.headers['Connection'] = 'keep-alive'
c.headers['Transfer-Encoding'] = 'chunked'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/event-stream') or {}
headers.add_custom('Cache-Control', 'no-cache') or {}
headers.add_custom('Connection', 'keep-alive') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// is_streaming_response - Check if a Context represents a streaming response
// Used by server backends to detect streaming responses
pub fn is_streaming_response(ctx Context) bool {
return '_stream' in ctx.store && ctx.store['_stream'] == 'true'
}
// get_stream_config - Get the stream configuration from Context
// Returns the StreamConfig if this is a streaming response, none otherwise
pub fn get_stream_config(ctx Context) ?StreamConfig {
if !is_streaming_response(ctx) {
return none
}
stream_id := ctx.store['_stream_id'] or { return none }
return retrieve_stream_config(stream_id)
}
// cleanup_stream_config - Remove stream configuration after use
// Should be called by server backends after streaming is complete
pub fn cleanup_stream_config(ctx Context) {
if stream_id := ctx.store['_stream_id'] {
remove_stream_config(stream_id)
}
}
// execute_stream - Execute a streaming response with the given writer
// This is the main entry point for server backends to handle streaming
//
// Parameters:
// ctx: The request Context with streaming configuration
// writer: The StreamWriter implementation for the server backend
//
// Returns:
// true if streaming was executed, false if not a streaming response
pub fn execute_stream(ctx Context, mut writer StreamWriter) bool {
config := get_stream_config(ctx) or { return false }
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the callback and handle errors
config.callback(mut stream_ctx) or {
// Handle error
if error_handler := config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
return true
}
+163
View File
@@ -0,0 +1,163 @@
// swagger.v - Swagger UI 中间件
// 本模块提供 Swagger UI 文档界面功能,类似于 Hono.js 的 @hono/swagger-ui 中间件
// 支持 OpenAPI 3.0/3.1 规范
module hono
import net.http
// ============================================================================
// Swagger UI 配置选项 (Task 7.1)
// ============================================================================
// SwaggerUIOptions - Swagger UI 配置选项
// 使用示例:
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
pub struct SwaggerUIOptions {
pub mut:
url string = '/doc' // OpenAPI 文档 URL
title string = 'API Documentation' // 页面标题
deep_linking bool = true // 启用深度链接
display_request_duration bool = true // 显示请求耗时
default_models_expand_depth int = 1 // 模型展开深度
doc_expansion string = 'list' // 文档展开方式: 'list', 'full', 'none'
filter bool // 启用过滤
show_extensions bool // 显示扩展
show_common_extensions bool = true // 显示常用扩展
try_it_out_enabled bool = true // 启用 Try it out
custom_css string // 自定义 CSS
custom_js string // 自定义 JavaScript
custom_css_url string // 自定义 CSS URL
custom_js_url string // 自定义 JavaScript URL
}
// ============================================================================
// Swagger UI HTML 生成 (Task 7.2)
// ============================================================================
// generate_swagger_html - 生成 Swagger UI HTML 页面
// 使用 CDN 链接加载 Swagger UI 资源
// 应用配置选项到 SwaggerUIBundle 配置
fn generate_swagger_html(options SwaggerUIOptions) string {
// 构建自定义 CSS URL 标签
custom_css_url_tag := if options.custom_css_url.len > 0 {
'<link rel="stylesheet" href="${options.custom_css_url}">'
} else {
''
}
// 构建自定义 JS URL 标签
custom_js_url_tag := if options.custom_js_url.len > 0 {
'<script src="${options.custom_js_url}"></script>'
} else {
''
}
// 构建自定义 CSS 内联样式
custom_css_inline := if options.custom_css.len > 0 {
options.custom_css
} else {
''
}
// 构建自定义 JS 内联脚本
custom_js_inline := if options.custom_js.len > 0 {
options.custom_js
} else {
''
}
// 转换布尔值为 JavaScript 字符串
deep_linking_str := if options.deep_linking { 'true' } else { 'false' }
display_request_duration_str := if options.display_request_duration { 'true' } else { 'false' }
filter_str := if options.filter { 'true' } else { 'false' }
show_extensions_str := if options.show_extensions { 'true' } else { 'false' }
show_common_extensions_str := if options.show_common_extensions { 'true' } else { 'false' }
try_it_out_enabled_str := if options.try_it_out_enabled { 'true' } else { 'false' }
return '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${options.title}</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
${custom_css_url_tag}
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
${custom_css_inline}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
${custom_js_url_tag}
<script>
window.onload = function() {
const ui = SwaggerUIBundle({
url: "${options.url}",
dom_id: \'#swagger-ui\',
deepLinking: ${deep_linking_str},
displayRequestDuration: ${display_request_duration_str},
defaultModelsExpandDepth: ${options.default_models_expand_depth},
docExpansion: "${options.doc_expansion}",
filter: ${filter_str},
showExtensions: ${show_extensions_str},
showCommonExtensions: ${show_common_extensions_str},
tryItOutEnabled: ${try_it_out_enabled_str},
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
window.ui = ui;
};
${custom_js_inline}
</script>
</body>
</html>'
}
// ============================================================================
// Swagger UI 中间件函数 (Task 7.3)
// ============================================================================
// swagger_ui - 创建 Swagger UI 处理器
// 返回处理器函数,设置正确的 Content-Type,返回生成的 HTML
// 使用示例:
// app.get('/ui', hono.swagger_ui(hono.SwaggerUIOptions{ url: '/doc' }))
// app.get('/docs', hono.swagger_ui()) // 使用默认选项
pub fn swagger_ui(options ...SwaggerUIOptions) fn (mut Context) http.Response {
// 获取选项,如果没有提供则使用默认值
opts := if options.len > 0 {
options[0]
} else {
SwaggerUIOptions{}
}
// 预生成 HTML 内容
html_content := generate_swagger_html(opts)
return fn [html_content] (mut c Context) http.Response {
return http.Response{
status_code: 200
header: http.new_header(key: .content_type, value: 'text/html; charset=utf-8')
body: html_content
}
}
}
// swagger_ui_handler - swagger_ui 的别名函数
// 使用示例:
// app.get('/swagger', hono.swagger_ui_handler(hono.SwaggerUIOptions{ url: '/api/doc' }))
pub fn swagger_ui_handler(options ...SwaggerUIOptions) fn (mut Context) http.Response {
return swagger_ui(...options)
}
+1006
View File
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
// 高并发基准测试 - 用于对比 v-hono 内外性能
// 参数与外层 benchmark.go 保持一致: 500 并发, 100万请求
//
// 使用方法:
// 1. 启动服务器: v -enable-globals run v-hono/tests/test_picoev_server.v
// 2. 运行测试: go run v-hono/tests/benchmark_compare.go
package main
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
baseURL = "http://127.0.0.1:9999"
connections = 500
requests = 1000000
timeout = 10 * time.Second
)
type Metrics struct {
TotalRequests int64
SuccessRequests int64
FailedRequests int64
TotalBytes int64
MinLatency time.Duration
MaxLatency time.Duration
TotalLatencyNs int64
StartTime time.Time
EndTime time.Time
}
func main() {
fmt.Println("🔥 V-Hono 高并发基准测试")
fmt.Println(strings.Repeat("=", 50))
fmt.Printf("服务器: %s\n", baseURL)
fmt.Printf("并发数: %d\n", connections)
fmt.Printf("请求数: %d\n", requests)
fmt.Printf("超时: %v\n", timeout)
fmt.Println()
// 检查服务器
if !checkServer() {
fmt.Println("❌ 服务器未运行")
fmt.Println(" 请先启动: v -enable-globals run v-hono/tests/test_picoev_server.v")
return
}
fmt.Println("✅ 服务器已就绪")
// 预热
fmt.Println("\n🔥 预热服务器...")
warmup()
fmt.Println("✅ 预热完成")
// 运行测试
testCases := []struct {
name string
path string
}{
{"健康检查", "/health"},
{"根路径", "/"},
{"JSON接口", "/json"},
{"动态路由", "/users/123"},
}
allResults := make(map[string]*Metrics)
for _, tc := range testCases {
fmt.Printf("\n🚀 测试: %s (%s)\n", tc.name, tc.path)
fmt.Println(strings.Repeat("-", 50))
metrics := runBenchmark(tc.path)
allResults[tc.name] = metrics
printMetrics(tc.name, metrics)
time.Sleep(2 * time.Second)
}
// 汇总
printSummary(allResults)
}
func checkServer() bool {
client := &http.Client{Timeout: 2 * time.Second}
for i := 0; i < 3; i++ {
resp, err := client.Get(baseURL + "/health")
if err == nil && resp.StatusCode == 200 {
resp.Body.Close()
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
func warmup() {
client := &http.Client{Timeout: 5 * time.Second}
for i := 0; i < 100; i++ {
resp, err := client.Get(baseURL + "/health")
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
}
func runBenchmark(endpoint string) *Metrics {
url := baseURL + endpoint
metrics := &Metrics{
StartTime: time.Now(),
MinLatency: time.Hour,
}
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: connections,
MaxIdleConnsPerHost: connections,
IdleConnTimeout: 30 * time.Second,
},
}
requestsPerWorker := requests / connections
var wg sync.WaitGroup
var latencyMutex sync.Mutex
// 进度显示
done := make(chan bool)
go showProgress(metrics, done)
// 启动工作协程
for i := 0; i < connections; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < requestsPerWorker; j++ {
start := time.Now()
resp, err := client.Get(url)
latency := time.Since(start)
atomic.AddInt64(&metrics.TotalRequests, 1)
atomic.AddInt64(&metrics.TotalLatencyNs, int64(latency))
latencyMutex.Lock()
if latency < metrics.MinLatency {
metrics.MinLatency = latency
}
if latency > metrics.MaxLatency {
metrics.MaxLatency = latency
}
latencyMutex.Unlock()
if err != nil {
atomic.AddInt64(&metrics.FailedRequests, 1)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil || resp.StatusCode >= 400 {
atomic.AddInt64(&metrics.FailedRequests, 1)
} else {
atomic.AddInt64(&metrics.SuccessRequests, 1)
atomic.AddInt64(&metrics.TotalBytes, int64(len(body)))
}
}
}()
}
wg.Wait()
metrics.EndTime = time.Now()
done <- true
return metrics
}
func showProgress(metrics *Metrics, done chan bool) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Println()
return
case <-ticker.C:
current := atomic.LoadInt64(&metrics.TotalRequests)
elapsed := time.Since(metrics.StartTime).Seconds()
rps := float64(current) / elapsed
progress := float64(current) / float64(requests) * 100
fmt.Printf("\r进度: %.1f%% | 完成: %d | RPS: %.0f | 耗时: %.1fs",
progress, current, rps, elapsed)
}
}
}
func printMetrics(name string, m *Metrics) {
duration := m.EndTime.Sub(m.StartTime)
avgLatency := time.Duration(m.TotalLatencyNs / m.TotalRequests)
fmt.Printf("\n📊 %s - 测试结果\n", name)
fmt.Println(strings.Repeat("=", 40))
fmt.Printf("总请求数: %d\n", m.TotalRequests)
fmt.Printf("成功请求: %d\n", m.SuccessRequests)
fmt.Printf("失败请求: %d\n", m.FailedRequests)
fmt.Printf("成功率: %.2f%%\n", float64(m.SuccessRequests)/float64(m.TotalRequests)*100)
fmt.Printf("总耗时: %.2f 秒\n", duration.Seconds())
fmt.Printf("RPS: %.0f 请求/秒\n", float64(m.TotalRequests)/duration.Seconds())
fmt.Printf("最小延迟: %v\n", m.MinLatency)
fmt.Printf("最大延迟: %v\n", m.MaxLatency)
fmt.Printf("平均延迟: %v\n", avgLatency)
if m.TotalBytes > 0 {
throughputMB := float64(m.TotalBytes) / 1024 / 1024 / duration.Seconds()
fmt.Printf("数据传输: %.2f MB\n", float64(m.TotalBytes)/1024/1024)
fmt.Printf("吞吐量: %.2f MB/s\n", throughputMB)
}
}
func printSummary(results map[string]*Metrics) {
fmt.Printf("\n🏆 性能汇总报告\n")
fmt.Println(strings.Repeat("=", 60))
fmt.Printf("%-15s %-12s %-10s %-12s %-10s\n", "接口", "RPS", "成功率", "平均延迟", "吞吐量")
fmt.Println(strings.Repeat("-", 60))
for name, metrics := range results {
duration := metrics.EndTime.Sub(metrics.StartTime)
rps := float64(metrics.TotalRequests) / duration.Seconds()
successRate := float64(metrics.SuccessRequests) / float64(metrics.TotalRequests) * 100
avgLatency := time.Duration(metrics.TotalLatencyNs / metrics.TotalRequests)
throughput := float64(metrics.TotalBytes) / 1024 / 1024 / duration.Seconds()
fmt.Printf("%-15s %-12.0f %-9.1f%% %-12v %-8.1fMB/s\n",
name, rps, successRate, avgLatency, throughput)
}
fmt.Println("\n✅ 测试完成!")
}
+1 -1
View File
@@ -3,7 +3,7 @@
module main
import time
import hono
import meiseayoung.hono
import net.http
const iterations = 100_000
+1 -1
View File
@@ -4,7 +4,7 @@
module main
import time
import hono
import meiseayoung.hono
import net.http
// ============================================
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"flag"
"fmt"
"io"
"math/rand"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
func main() {
url := flag.String("url", "http://127.0.0.1:3000", "Target base URL")
connections := flag.Int("c", 500, "Number of connections")
requests := flag.Int("n", 1000000, "Total number of requests")
flag.Parse()
fmt.Printf("Benchmarking %s/users/:id\n", *url)
fmt.Printf("Connections: %d, Requests: %d\n\n", *connections, *requests)
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: *connections,
MaxIdleConnsPerHost: *connections,
MaxConnsPerHost: *connections,
IdleConnTimeout: 90 * time.Second,
},
Timeout: 10 * time.Second,
}
var completed int64
var errors int64
var latencies []int64
var mu sync.Mutex
totalReqs := int64(*requests)
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < *connections; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
if atomic.LoadInt64(&completed)+atomic.LoadInt64(&errors) >= totalReqs {
return
}
reqStart := time.Now()
userId := rand.Intn(10000)
targetUrl := fmt.Sprintf("%s/users/%d", *url, userId)
resp, err := client.Get(targetUrl)
if err != nil {
atomic.AddInt64(&errors, 1)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
latency := time.Since(reqStart).Microseconds()
mu.Lock()
latencies = append(latencies, latency)
mu.Unlock()
atomic.AddInt64(&completed, 1)
}
}()
}
wg.Wait()
elapsed := time.Since(start).Seconds()
total := atomic.LoadInt64(&completed)
errs := atomic.LoadInt64(&errors)
rps := float64(total) / elapsed
mu.Lock()
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
var avg, p50, p95, p99 int64
if len(latencies) > 0 {
var sum int64
for _, l := range latencies {
sum += l
}
avg = sum / int64(len(latencies))
p50 = latencies[len(latencies)*50/100]
p95 = latencies[len(latencies)*95/100]
p99 = latencies[len(latencies)*99/100]
}
mu.Unlock()
fmt.Println("Results:")
fmt.Printf(" Requests: %d\n", total)
fmt.Printf(" Errors: %d\n", errs)
fmt.Printf(" Duration: %.2fs\n", elapsed)
fmt.Printf(" RPS: %.0f\n", rps)
fmt.Printf(" Latency avg: %.2fms\n", float64(avg)/1000)
fmt.Printf(" Latency p50: %.2fms\n", float64(p50)/1000)
fmt.Printf(" Latency p95: %.2fms\n", float64(p95)/1000)
fmt.Printf(" Latency p99: %.2fms\n", float64(p99)/1000)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
import time
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
import os
import time
+873
View File
@@ -0,0 +1,873 @@
module main
import rand
import time
import crypto.sha1
import encoding.base64
import net.urllib
// ============================================================================
// Property 1: Upload-Download Round Trip (OSS)
// Property 4: Presigned URL Validity (OSS)
// Feature: v-hono-upload-integration, Property 1 & 4
// Validates: Requirements 4.4, 4.5
//
// Since we cannot test against a real Aliyun OSS server without credentials,
// these tests verify:
// 1. Aliyun OSS storage provider creation and configuration validation
// 2. Presigned URL generation format and structure
// 3. Aliyun OSS signing algorithm correctness
// 4. Internal/External endpoint switching
// ============================================================================
const test_iterations = 100
// ============================================================================
// Type definitions (copied from storage modules for standalone testing)
// ============================================================================
struct StorageResult {
pub:
success bool
object_key string
etag string
size i64
error_msg string
}
struct ObjectInfo {
pub:
key string
size i64
etag string
content_type string
last_modified i64
metadata map[string]string
}
struct ListOptions {
pub:
prefix string
delimiter string
max_keys int = 1000
start_after string
}
struct ListResult {
pub:
objects []ObjectInfo
common_prefixes []string
is_truncated bool
next_marker string
}
struct PresignOptions {
pub:
expires_in int = 3600
method string = 'GET'
content_type string
}
struct PartInfo {
pub:
part_number int
etag string
size i64
}
struct AliyunOSSConfig {
pub:
endpoint string
access_key_id string
access_key_secret string
internal_endpoint string
default_bucket string
}
struct ConfigValidationResult {
pub:
valid bool
missing_fields []string
error_message string
}
// ============================================================================
// AliyunOSS implementation (minimal for testing)
// ============================================================================
struct AliyunOSS {
config AliyunOSSConfig
mut:
multipart_uploads map[string]OSSMultipartUploadState
use_internal bool
}
struct OSSMultipartUploadState {
mut:
bucket string
key string
upload_id string
content_type string
parts map[int]PartInfo
created_at i64
}
fn validate_aliyun_oss_config(config AliyunOSSConfig) ConfigValidationResult {
mut missing := []string{}
if config.endpoint == '' {
missing << 'aliyun_oss.endpoint'
}
if config.access_key_id == '' {
missing << 'aliyun_oss.access_key_id'
}
if config.access_key_secret == '' {
missing << 'aliyun_oss.access_key_secret'
}
if config.default_bucket == '' {
missing << 'aliyun_oss.default_bucket'
}
if missing.len > 0 {
return ConfigValidationResult{
valid: false
missing_fields: missing
error_message: 'Missing required fields: ${missing.join(", ")}'
}
}
return ConfigValidationResult{
valid: true
missing_fields: []string{}
error_message: ''
}
}
fn new_aliyun_oss(config AliyunOSSConfig) !AliyunOSS {
validation := validate_aliyun_oss_config(config)
if !validation.valid {
return error(validation.error_message)
}
return AliyunOSS{
config: config
multipart_uploads: map[string]OSSMultipartUploadState{}
use_internal: false
}
}
fn new_aliyun_oss_internal(config AliyunOSSConfig) !AliyunOSS {
validation := validate_aliyun_oss_config(config)
if !validation.valid {
return error(validation.error_message)
}
if config.internal_endpoint == '' {
return error('Internal endpoint is not configured')
}
return AliyunOSS{
config: config
multipart_uploads: map[string]OSSMultipartUploadState{}
use_internal: true
}
}
fn (o AliyunOSS) provider_name() string {
return 'aliyun_oss'
}
fn (o AliyunOSS) get_current_endpoint() string {
if o.use_internal && o.config.internal_endpoint != '' {
return o.config.internal_endpoint
}
return o.config.endpoint
}
fn (mut o AliyunOSS) switch_to_internal() ! {
if o.config.internal_endpoint == '' {
return error('Internal endpoint is not configured')
}
o.use_internal = true
}
fn (mut o AliyunOSS) switch_to_external() {
o.use_internal = false
}
fn (o AliyunOSS) get_host(bucket string) string {
endpoint := o.get_current_endpoint()
if bucket != '' {
return '${bucket}.${endpoint}'
}
return endpoint
}
// URL encoding for key (preserving /)
fn (o AliyunOSS) encode_key(key string) string {
parts := key.split('/')
mut encoded_parts := []string{}
for part in parts {
encoded_parts << urllib.query_escape(part)
}
return encoded_parts.join('/')
}
// Build canonicalized resource
fn (o AliyunOSS) build_canonicalized_resource(bucket string, key string, query_params map[string]string) string {
mut resource := ''
if bucket != '' {
resource = '/${bucket}'
}
if key != '' {
resource += '/${key}'
} else if bucket != '' {
resource += '/'
} else {
resource = '/'
}
sub_resources := [
'acl', 'uploads', 'location', 'cors', 'logging', 'website', 'referer',
'lifecycle', 'delete', 'append', 'tagging', 'objectMeta', 'uploadId',
'partNumber', 'security-token', 'position', 'img', 'style', 'styleName',
'replication', 'replicationProgress', 'replicationLocation', 'cname',
'bucketInfo', 'comp', 'qos', 'live', 'status', 'vod', 'startTime',
'endTime', 'symlink', 'x-oss-process', 'response-content-type',
'response-content-language', 'response-expires', 'response-cache-control',
'response-content-disposition', 'response-content-encoding',
]
mut sub_params := []string{}
for param in sub_resources {
if param in query_params {
value := query_params[param]
if value != '' {
sub_params << '${param}=${value}'
} else {
sub_params << param
}
}
}
if sub_params.len > 0 {
resource += '?' + sub_params.join('&')
}
return resource
}
// HMAC-SHA1 implementation
fn oss_test_hmac_sha1(key []u8, data []u8) []u8 {
block_size := 64
mut k := key.clone()
if k.len > block_size {
k = sha1.sum(k)
}
for k.len < block_size {
k << u8(0)
}
mut i_pad := []u8{len: block_size}
mut o_pad := []u8{len: block_size}
for i in 0 .. block_size {
i_pad[i] = k[i] ^ u8(0x36)
o_pad[i] = k[i] ^ u8(0x5c)
}
mut inner_data := i_pad.clone()
inner_data << data
inner_hash := sha1.sum(inner_data)
mut outer_data := o_pad.clone()
outer_data << inner_hash
return sha1.sum(outer_data)
}
fn (o AliyunOSS) calculate_signature(string_to_sign string) string {
signature := oss_test_hmac_sha1(o.config.access_key_secret.bytes(), string_to_sign.bytes())
return base64.encode(signature)
}
fn (o AliyunOSS) presign_url(bucket string, key string, options PresignOptions) !string {
now := time.utc()
expires := now.unix() + i64(options.expires_in)
mut content_type := ''
if options.content_type != '' {
content_type = options.content_type
}
canonicalized_resource := o.build_canonicalized_resource(bucket, key, map[string]string{})
string_to_sign := '${options.method}\n\n${content_type}\n${expires}\n${canonicalized_resource}'
signature := o.calculate_signature(string_to_sign)
host := o.get_host(bucket)
encoded_key := o.encode_key(key)
mut url := 'https://${host}/${encoded_key}'
url += '?OSSAccessKeyId=${urllib.query_escape(o.config.access_key_id)}'
url += '&Expires=${expires}'
url += '&Signature=${urllib.query_escape(signature)}'
return url
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Aliyun OSS Storage 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
fn generate_random_string(min_len int, max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
fn generate_random_oss_config() AliyunOSSConfig {
regions := ['cn-hangzhou', 'cn-shanghai', 'cn-beijing', 'cn-shenzhen', 'cn-hongkong']
region_idx := rand.int_in_range(0, regions.len) or { 0 }
region := regions[region_idx]
return AliyunOSSConfig{
endpoint: 'oss-${region}.aliyuncs.com'
access_key_id: generate_random_string(16, 24)
access_key_secret: generate_random_string(30, 40)
internal_endpoint: 'oss-${region}-internal.aliyuncs.com'
default_bucket: generate_random_string(5, 15).to_lower()
}
}
fn generate_random_oss_config_no_internal() AliyunOSSConfig {
regions := ['cn-hangzhou', 'cn-shanghai', 'cn-beijing', 'cn-shenzhen']
region_idx := rand.int_in_range(0, regions.len) or { 0 }
region := regions[region_idx]
return AliyunOSSConfig{
endpoint: 'oss-${region}.aliyuncs.com'
access_key_id: generate_random_string(16, 24)
access_key_secret: generate_random_string(30, 40)
internal_endpoint: ''
default_bucket: generate_random_string(5, 15).to_lower()
}
}
fn generate_random_filename() string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(5, 20) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
extensions := ['.txt', '.bin', '.dat', '.json', '.xml', '.png', '.jpg']
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
return result + extensions[ext_idx]
}
// ============================================================================
// Property 1: Aliyun OSS Storage Provider Creation
// For any valid Aliyun OSS configuration, creating a storage provider should succeed
// Validates: Requirements 4.1, 4.2
// ============================================================================
fn test_property_1_oss_provider_creation() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config()
storage := new_aliyun_oss(config) or {
println(' Iteration ${i}: Failed to create Aliyun OSS storage: ${err}')
return false
}
if storage.provider_name() != 'aliyun_oss' {
println(' Iteration ${i}: Provider name mismatch. Expected: aliyun_oss, Got: ${storage.provider_name()}')
return false
}
}
return true
}
// ============================================================================
// Property 2: Invalid Configuration Rejection
// For any Aliyun OSS configuration with missing required fields, creation should fail
// Validates: Requirements 4.2
// ============================================================================
fn test_property_2_invalid_config_rejection() bool {
// Test missing endpoint
for _ in 0 .. 10 {
config := AliyunOSSConfig{
endpoint: ''
access_key_id: generate_random_string(16, 24)
access_key_secret: generate_random_string(30, 40)
default_bucket: generate_random_string(5, 15)
}
_ := new_aliyun_oss(config) or {
continue
}
println(' Missing endpoint should have failed')
return false
}
// Test missing access_key_id
for _ in 0 .. 10 {
config := AliyunOSSConfig{
endpoint: 'oss-cn-hangzhou.aliyuncs.com'
access_key_id: ''
access_key_secret: generate_random_string(30, 40)
default_bucket: generate_random_string(5, 15)
}
_ := new_aliyun_oss(config) or {
continue
}
println(' Missing access_key_id should have failed')
return false
}
// Test missing access_key_secret
for _ in 0 .. 10 {
config := AliyunOSSConfig{
endpoint: 'oss-cn-hangzhou.aliyuncs.com'
access_key_id: generate_random_string(16, 24)
access_key_secret: ''
default_bucket: generate_random_string(5, 15)
}
_ := new_aliyun_oss(config) or {
continue
}
println(' Missing access_key_secret should have failed')
return false
}
// Test missing default_bucket
for _ in 0 .. 10 {
config := AliyunOSSConfig{
endpoint: 'oss-cn-hangzhou.aliyuncs.com'
access_key_id: generate_random_string(16, 24)
access_key_secret: generate_random_string(30, 40)
default_bucket: ''
}
_ := new_aliyun_oss(config) or {
continue
}
println(' Missing default_bucket should have failed')
return false
}
return true
}
// ============================================================================
// Property 3: Internal/External Endpoint Switching
// For any Aliyun OSS configuration with internal endpoint, switching should work correctly
// Validates: Requirements 4.3
// ============================================================================
fn test_property_3_endpoint_switching() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config()
mut storage := new_aliyun_oss(config) or {
println(' Iteration ${i}: Failed to create storage: ${err}')
return false
}
// Verify initial endpoint is external
initial_endpoint := storage.get_current_endpoint()
if initial_endpoint != config.endpoint {
println(' Iteration ${i}: Initial endpoint mismatch. Expected: ${config.endpoint}, Got: ${initial_endpoint}')
return false
}
// Switch to internal endpoint
storage.switch_to_internal() or {
println(' Iteration ${i}: Failed to switch to internal endpoint: ${err}')
return false
}
// Verify endpoint is now internal
internal_endpoint := storage.get_current_endpoint()
if internal_endpoint != config.internal_endpoint {
println(' Iteration ${i}: Internal endpoint mismatch. Expected: ${config.internal_endpoint}, Got: ${internal_endpoint}')
return false
}
// Switch back to external endpoint
storage.switch_to_external()
// Verify endpoint is back to external
external_endpoint := storage.get_current_endpoint()
if external_endpoint != config.endpoint {
println(' Iteration ${i}: External endpoint mismatch after switch back. Expected: ${config.endpoint}, Got: ${external_endpoint}')
return false
}
}
return true
}
// ============================================================================
// Property 4: Internal Endpoint Switching Without Config Should Fail
// For any Aliyun OSS configuration without internal endpoint, switching should fail
// Validates: Requirements 4.3
// ============================================================================
fn test_property_4_internal_switch_without_config() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config_no_internal()
mut storage := new_aliyun_oss(config) or {
println(' Iteration ${i}: Failed to create storage: ${err}')
return false
}
// Attempt to switch to internal endpoint should fail
storage.switch_to_internal() or {
continue
}
println(' Iteration ${i}: Switch to internal should have failed without internal_endpoint configured')
return false
}
return true
}
// ============================================================================
// Property 5: Presigned URL Format Validity
// For any valid Aliyun OSS configuration and object key, the presigned URL should:
// - Contain the correct scheme (https)
// - Contain the correct host (bucket.endpoint)
// - Contain the object key
// - Contain required OSS signature parameters
// Validates: Requirements 4.4, 4.5
// ============================================================================
fn test_property_5_presigned_url_format() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config()
bucket := config.default_bucket
key := generate_random_filename()
storage := new_aliyun_oss(config) or {
println(' Iteration ${i}: Failed to create storage: ${err}')
return false
}
options := PresignOptions{
expires_in: 3600
method: 'GET'
}
url := storage.presign_url(bucket, key, options) or {
println(' Iteration ${i}: Failed to generate presigned URL: ${err}')
return false
}
// 1. Should start with https://
if !url.starts_with('https://') {
println(' Iteration ${i}: URL should start with https://, got: ${url}')
return false
}
// 2. Should contain bucket in host (virtual-hosted-style)
expected_host := '${bucket}.${config.endpoint}'
if !url.contains(expected_host) {
println(' Iteration ${i}: URL should contain host "${expected_host}", got: ${url}')
return false
}
// 3. Should contain the object key
if !url.contains(key) {
println(' Iteration ${i}: URL should contain key "${key}", got: ${url}')
return false
}
// 4. Should contain OSS signature parameters
required_params := ['OSSAccessKeyId', 'Expires', 'Signature']
for param in required_params {
if !url.contains(param) {
println(' Iteration ${i}: URL missing required parameter "${param}", got: ${url}')
return false
}
}
}
return true
}
// ============================================================================
// Property 6: Presigned URL Expiration Parameter
// For any expiration time, the presigned URL should contain the correct Expires value
// Validates: Requirements 4.4
// ============================================================================
fn test_property_6_presigned_url_expiration() bool {
config := generate_random_oss_config()
bucket := config.default_bucket
key := generate_random_filename()
storage := new_aliyun_oss(config) or {
println(' Failed to create storage: ${err}')
return false
}
expiration_times := [60, 300, 900, 1800, 3600, 7200, 86400]
for expires_in in expiration_times {
options := PresignOptions{
expires_in: expires_in
method: 'GET'
}
url := storage.presign_url(bucket, key, options) or {
println(' Failed to generate presigned URL with expires_in=${expires_in}: ${err}')
return false
}
// Verify URL contains Expires parameter
if !url.contains('Expires=') {
println(' URL should contain Expires parameter, got: ${url}')
return false
}
// The Expires value should be a Unix timestamp (current time + expires_in)
if expires_idx := url.index('Expires=') {
expires_start := expires_idx + 8
mut expires_end := expires_start
for expires_end < url.len && url[expires_end] != `&` {
expires_end++
}
expires_str := url[expires_start..expires_end]
expires_value := expires_str.i64()
// Should be a reasonable Unix timestamp (after year 2020)
if expires_value < 1577836800 {
println(' Expires value ${expires_value} seems too small')
return false
}
}
}
return true
}
// ============================================================================
// Property 7: Presigned URL Method Parameter
// For different HTTP methods, the presigned URL should be generated correctly
// Validates: Requirements 4.4
// ============================================================================
fn test_property_7_presigned_url_methods() bool {
config := generate_random_oss_config()
bucket := config.default_bucket
key := generate_random_filename()
storage := new_aliyun_oss(config) or {
println(' Failed to create storage: ${err}')
return false
}
methods := ['GET', 'PUT', 'DELETE', 'HEAD']
for method in methods {
options := PresignOptions{
expires_in: 3600
method: method
}
url := storage.presign_url(bucket, key, options) or {
println(' Failed to generate presigned URL for method ${method}: ${err}')
return false
}
if url == '' {
println(' Empty URL generated for method ${method}')
return false
}
if !url.contains('Signature=') {
println(' URL for method ${method} missing signature')
return false
}
}
return true
}
// ============================================================================
// Property 8: Nested Path Key Handling
// For keys with nested paths, the presigned URL should handle them correctly
// Validates: Requirements 4.4
// ============================================================================
fn test_property_8_nested_path_handling() bool {
config := generate_random_oss_config()
bucket := config.default_bucket
storage := new_aliyun_oss(config) or {
println(' Failed to create storage: ${err}')
return false
}
for i in 0 .. test_iterations {
// Generate nested path
depth := rand.int_in_range(1, 5) or { 2 }
mut path_parts := []string{}
for _ in 0 .. depth {
part_len := rand.int_in_range(3, 10) or { 5 }
mut part := ''
for _ in 0 .. part_len {
idx := rand.int_in_range(0, 26) or { 0 }
part += ('a'[0] + u8(idx)).ascii_str()
}
path_parts << part
}
path_parts << generate_random_filename()
key := path_parts.join('/')
options := PresignOptions{
expires_in: 3600
method: 'GET'
}
url := storage.presign_url(bucket, key, options) or {
println(' Iteration ${i}: Failed to generate presigned URL for nested key "${key}": ${err}')
return false
}
// URL should contain the key parts
for part in path_parts {
if !url.contains(part) {
println(' Iteration ${i}: URL should contain path part "${part}", got: ${url}')
return false
}
}
}
return true
}
// ============================================================================
// Property 9: Internal Endpoint Provider Creation
// For any valid Aliyun OSS configuration with internal endpoint, creating an internal provider should succeed
// Validates: Requirements 4.3
// ============================================================================
fn test_property_9_internal_provider_creation() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config()
storage := new_aliyun_oss_internal(config) or {
println(' Iteration ${i}: Failed to create internal Aliyun OSS storage: ${err}')
return false
}
if storage.provider_name() != 'aliyun_oss' {
println(' Iteration ${i}: Provider name mismatch. Expected: aliyun_oss, Got: ${storage.provider_name()}')
return false
}
current_endpoint := storage.get_current_endpoint()
if current_endpoint != config.internal_endpoint {
println(' Iteration ${i}: Endpoint should be internal. Expected: ${config.internal_endpoint}, Got: ${current_endpoint}')
return false
}
}
return true
}
// ============================================================================
// Property 10: Internal Provider Creation Without Config Should Fail
// For any Aliyun OSS configuration without internal endpoint, creating an internal provider should fail
// Validates: Requirements 4.3
// ============================================================================
fn test_property_10_internal_provider_without_config() bool {
for i in 0 .. test_iterations {
config := generate_random_oss_config_no_internal()
_ := new_aliyun_oss_internal(config) or {
continue
}
println(' Iteration ${i}: Creating internal provider should have failed without internal_endpoint configured')
return false
}
return true
}
fn main() {
println('🚀 开始 Aliyun OSS Storage 属性测试...')
println('Feature: v-hono-upload-integration, Property 1 & 4: Upload-Download Round Trip (OSS) & Presigned URL Validity')
println('Validates: Requirements 4.4, 4.5')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(67890)])
mut stats := PropertyTestStats{}
// Run property tests
stats.run_property_test('Property 1: Aliyun OSS Storage Provider Creation', test_property_1_oss_provider_creation)
stats.run_property_test('Property 2: Invalid Configuration Rejection', test_property_2_invalid_config_rejection)
stats.run_property_test('Property 3: Internal/External Endpoint Switching', test_property_3_endpoint_switching)
stats.run_property_test('Property 4: Internal Switch Without Config Should Fail', test_property_4_internal_switch_without_config)
stats.run_property_test('Property 5: Presigned URL Format Validity', test_property_5_presigned_url_format)
stats.run_property_test('Property 6: Presigned URL Expiration Parameter', test_property_6_presigned_url_expiration)
stats.run_property_test('Property 7: Presigned URL Method Parameter', test_property_7_presigned_url_methods)
stats.run_property_test('Property 8: Nested Path Key Handling', test_property_8_nested_path_handling)
stats.run_property_test('Property 9: Internal Endpoint Provider Creation', test_property_9_internal_provider_creation)
stats.run_property_test('Property 10: Internal Provider Without Config Should Fail', test_property_10_internal_provider_without_config)
stats.print_summary()
if stats.failed_tests > 0 {
exit(1)
}
}
@@ -0,0 +1,458 @@
module main
import rand
import time
import os
import crypto.md5
// ============================================================================
// Property 10: Backward Compatibility
// Feature: v-hono-upload-integration, Property 10: Backward Compatibility
// Validates: Requirements 11.2, 11.4
//
// *For any* existing ChunkUploadManager usage without storage configuration,
// the system should behave exactly as before, using local file storage with
// the same response format.
// ============================================================================
const test_iterations = 100
const test_temp_dir = './test_backward_compat_chunks'
const test_upload_dir = './test_backward_compat_files'
const test_db_path = './test_backward_compat.db'
// ============================================================================
// Type definitions (copied from upload module for standalone testing)
// ============================================================================
struct ChunkUploadConfig {
pub:
chunk_size int = 1024 * 1024
max_file_size int = 1024 * 1024 * 1024
max_chunk_size int = 10 * 1024 * 1024
temp_dir string = './uploads/chunks'
upload_dir string = './uploads/files'
cleanup_delay int = 3600
clear_chunks_on_complete bool
db_path string = './uploads/files.db'
merge_buffer_size int = 8192
use_file_service bool
}
struct FileUploadStatus {
pub:
file_hash string
filename string
total_chunks int
file_size int
chunk_size int
created_at int
pub mut:
uploaded_chunks []int
status string
updated_at int
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Backward Compatibility 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
fn generate_random_bytes(min_len int, max_len int) []u8 {
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := []u8{len: len}
for i in 0 .. len {
result[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_random_filename() string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(5, 20) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
extensions := ['.txt', '.bin', '.dat', '.json', '.xml']
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
return result + extensions[ext_idx]
}
fn calculate_md5_hash(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return result
}
fn cleanup_test_dirs() {
os.rmdir_all(test_temp_dir) or {}
os.rmdir_all(test_upload_dir) or {}
os.rm(test_db_path) or {}
os.rm('${test_db_path}-shm') or {}
os.rm('${test_db_path}-wal') or {}
}
// ============================================================================
// Property 10.1: Default Config Backward Compatibility
// When no storage configuration is provided, ChunkUploadConfig should use
// default values that match the original behavior
// ============================================================================
fn test_property_10_1_default_config_backward_compat() bool {
for i in 0 .. test_iterations {
// Create config with default values (no use_file_service)
config := ChunkUploadConfig{
temp_dir: test_temp_dir
upload_dir: test_upload_dir
db_path: test_db_path
}
// Verify default values match original behavior
if config.chunk_size != 1024 * 1024 {
println(' Iteration ${i}: Default chunk_size mismatch. Expected: ${1024 * 1024}, Got: ${config.chunk_size}')
return false
}
if config.max_file_size != 1024 * 1024 * 1024 {
println(' Iteration ${i}: Default max_file_size mismatch. Expected: ${1024 * 1024 * 1024}, Got: ${config.max_file_size}')
return false
}
if config.max_chunk_size != 10 * 1024 * 1024 {
println(' Iteration ${i}: Default max_chunk_size mismatch. Expected: ${10 * 1024 * 1024}, Got: ${config.max_chunk_size}')
return false
}
if config.cleanup_delay != 3600 {
println(' Iteration ${i}: Default cleanup_delay mismatch. Expected: 3600, Got: ${config.cleanup_delay}')
return false
}
if config.merge_buffer_size != 8192 {
println(' Iteration ${i}: Default merge_buffer_size mismatch. Expected: 8192, Got: ${config.merge_buffer_size}')
return false
}
// use_file_service should default to false
if config.use_file_service != false {
println(' Iteration ${i}: Default use_file_service should be false')
return false
}
}
return true
}
// ============================================================================
// Property 10.2: FileUploadStatus Structure Compatibility
// The FileUploadStatus structure should maintain the same fields and types
// ============================================================================
fn test_property_10_2_file_upload_status_compat() bool {
for i in 0 .. test_iterations {
// Generate random values
file_hash := calculate_md5_hash(generate_random_bytes(10, 100))
filename := generate_random_filename()
total_chunks := rand.int_in_range(1, 100) or { 10 }
file_size := rand.int_in_range(1000, 1000000) or { 10000 }
chunk_size := rand.int_in_range(1024, 1024 * 1024) or { 1024 * 1024 }
now := int(time.now().unix())
// Create FileUploadStatus with all fields
status := FileUploadStatus{
file_hash: file_hash
filename: filename
total_chunks: total_chunks
file_size: file_size
chunk_size: chunk_size
created_at: now
updated_at: now
uploaded_chunks: [0, 1, 2]
status: 'uploading'
}
// Verify all fields are accessible and have correct values
if status.file_hash != file_hash {
println(' Iteration ${i}: file_hash mismatch')
return false
}
if status.filename != filename {
println(' Iteration ${i}: filename mismatch')
return false
}
if status.total_chunks != total_chunks {
println(' Iteration ${i}: total_chunks mismatch')
return false
}
if status.file_size != file_size {
println(' Iteration ${i}: file_size mismatch')
return false
}
if status.chunk_size != chunk_size {
println(' Iteration ${i}: chunk_size mismatch')
return false
}
if status.created_at != now {
println(' Iteration ${i}: created_at mismatch')
return false
}
if status.uploaded_chunks.len != 3 {
println(' Iteration ${i}: uploaded_chunks length mismatch')
return false
}
if status.status != 'uploading' {
println(' Iteration ${i}: status mismatch')
return false
}
}
return true
}
// ============================================================================
// Property 10.3: Local File Storage Path Consistency
// When use_file_service is false, files should be stored in upload_dir
// with the same path format as before
// ============================================================================
fn test_property_10_3_local_storage_path_consistency() bool {
cleanup_test_dirs()
defer {
cleanup_test_dirs()
}
for i in 0 .. test_iterations {
// Generate random file data
data := generate_random_bytes(100, 1000)
file_hash := calculate_md5_hash(data)
filename := generate_random_filename()
// Get file extension
parts := filename.split('.')
file_ext := if parts.len > 1 { '.${parts.last()}' } else { '' }
// Expected final filename format: {file_hash}{file_ext}
expected_final_filename := '${file_hash}${file_ext}'
expected_final_path := os.join_path(test_upload_dir, expected_final_filename)
// Verify the path format is consistent
if !expected_final_path.contains(file_hash) {
println(' Iteration ${i}: Final path should contain file hash')
return false
}
if file_ext != '' && !expected_final_path.ends_with(file_ext) {
println(' Iteration ${i}: Final path should end with file extension')
return false
}
}
return true
}
// ============================================================================
// Property 10.4: Chunk Directory Structure Consistency
// Chunks should be stored in temp_dir/{file_hash}/{chunk_size}/ format
// ============================================================================
fn test_property_10_4_chunk_directory_structure() bool {
cleanup_test_dirs()
defer {
cleanup_test_dirs()
}
for i in 0 .. test_iterations {
// Generate random values
data := generate_random_bytes(100, 1000)
file_hash := calculate_md5_hash(data)
chunk_size := rand.int_in_range(1024, 1024 * 1024) or { 1024 * 1024 }
chunk_index := rand.int_in_range(0, 100) or { 0 }
// Expected chunk directory format
expected_chunk_dir := os.join_path(test_temp_dir, file_hash, chunk_size.str())
expected_chunk_path := os.join_path(expected_chunk_dir, 'chunk_${chunk_index}.part')
// Verify the path format is consistent
if !expected_chunk_dir.contains(file_hash) {
println(' Iteration ${i}: Chunk dir should contain file hash')
return false
}
if !expected_chunk_dir.contains(chunk_size.str()) {
println(' Iteration ${i}: Chunk dir should contain chunk size')
return false
}
if !expected_chunk_path.ends_with('.part') {
println(' Iteration ${i}: Chunk file should end with .part')
return false
}
}
return true
}
// ============================================================================
// Property 10.5: Config with use_file_service=false Behaves Like Original
// When use_file_service is explicitly set to false, behavior should be
// identical to the original implementation
// ============================================================================
fn test_property_10_5_explicit_no_file_service() bool {
for i in 0 .. test_iterations {
// Create config with explicit use_file_service = false
config := ChunkUploadConfig{
temp_dir: test_temp_dir
upload_dir: test_upload_dir
db_path: test_db_path
use_file_service: false
}
// Verify use_file_service is false
if config.use_file_service != false {
println(' Iteration ${i}: use_file_service should be false')
return false
}
// Verify upload_dir is used (not empty)
if config.upload_dir == '' {
println(' Iteration ${i}: upload_dir should not be empty when use_file_service is false')
return false
}
// Verify temp_dir is used (not empty)
if config.temp_dir == '' {
println(' Iteration ${i}: temp_dir should not be empty')
return false
}
}
return true
}
// ============================================================================
// Property 10.6: Response Format Consistency
// The response format for chunk upload should remain compatible
// ============================================================================
fn test_property_10_6_response_format_consistency() bool {
// Test that expected response fields are present in the format
// Original format: {"success": true, "chunk_index": N, "all_chunk_uploaded": false, "message": "..."}
// Or: {"success": true, "all_chunk_uploaded": true, "file_path": "...", "file_uuid": "...", "message": "..."}
for i in 0 .. test_iterations {
chunk_index := rand.int_in_range(0, 100) or { 0 }
file_hash := calculate_md5_hash(generate_random_bytes(10, 100))
file_ext := '.txt'
file_uuid := 'test-uuid-${i}'
// Test partial upload response format
partial_response := '{"success": true, "chunk_index": ${chunk_index}, "all_chunk_uploaded": false, "message": "Chunk uploaded successfully"}'
if !partial_response.contains('"success": true') {
println(' Iteration ${i}: Partial response should contain success field')
return false
}
if !partial_response.contains('"chunk_index":') {
println(' Iteration ${i}: Partial response should contain chunk_index field')
return false
}
if !partial_response.contains('"all_chunk_uploaded": false') {
println(' Iteration ${i}: Partial response should contain all_chunk_uploaded field')
return false
}
// Test complete upload response format (local storage)
file_path := os.join_path(test_upload_dir, '${file_hash}${file_ext}')
complete_response := '{"success": true, "all_chunk_uploaded": true, "file_path": "${file_path}", "file_uuid": "${file_uuid}", "message": "File merged successfully"}'
if !complete_response.contains('"success": true') {
println(' Iteration ${i}: Complete response should contain success field')
return false
}
if !complete_response.contains('"all_chunk_uploaded": true') {
println(' Iteration ${i}: Complete response should contain all_chunk_uploaded field')
return false
}
if !complete_response.contains('"file_path":') {
println(' Iteration ${i}: Complete response should contain file_path field')
return false
}
if !complete_response.contains('"file_uuid":') {
println(' Iteration ${i}: Complete response should contain file_uuid field')
return false
}
}
return true
}
// ============================================================================
// Main test runner
// ============================================================================
fn main() {
println('=== Property 10: Backward Compatibility 属性测试 ===')
println('Feature: v-hono-upload-integration')
println('Validates: Requirements 11.2, 11.4')
println('')
rand.seed([u32(time.now().unix()), u32(time.now().unix() >> 32)])
mut stats := PropertyTestStats{}
stats.run_property_test('Property 10.1: Default Config Backward Compatibility', test_property_10_1_default_config_backward_compat)
stats.run_property_test('Property 10.2: FileUploadStatus Structure Compatibility', test_property_10_2_file_upload_status_compat)
stats.run_property_test('Property 10.3: Local Storage Path Consistency', test_property_10_3_local_storage_path_consistency)
stats.run_property_test('Property 10.4: Chunk Directory Structure Consistency', test_property_10_4_chunk_directory_structure)
stats.run_property_test('Property 10.5: Explicit No FileService Behavior', test_property_10_5_explicit_no_file_service)
stats.run_property_test('Property 10.6: Response Format Consistency', test_property_10_6_response_format_consistency)
stats.print_summary()
// Exit with error code if any tests failed
if stats.failed_tests > 0 {
exit(1)
}
}
+380
View File
@@ -0,0 +1,380 @@
import meiseayoung.hono
import net.http
import rand
import time
// Bearer Auth Middleware 属性测试
// Property-Based Testing for Bearer Auth functionality
const test_iterations = 100
struct BearerAuthTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats BearerAuthTestStats) run_property_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 BearerAuthTestStats) print_summary() {
println('\n=== Bearer Auth Middleware 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 生成随机的 token
fn generate_random_token() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(16, 64) or { 32 }
mut token := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
token += chars[idx].ascii_str()
}
return token
}
// 生成随机的 realm
fn generate_random_realm() string {
realms := ['api', 'admin', 'user', 'protected', 'secure', 'private']
idx := rand.int_in_range(0, realms.len) or { 0 }
return realms[idx]
}
// 生成随机的 prefix
fn generate_random_prefix() string {
prefixes := ['Bearer', 'Token', 'ApiKey', 'Auth']
idx := rand.int_in_range(0, prefixes.len) or { 0 }
return prefixes[idx]
}
// 创建带 Authorization 头的测试 Context
fn create_bearer_context_with_auth(auth_header string) hono.Context {
mut headers := http.new_header()
if auth_header.len > 0 {
headers.add_custom('Authorization', auth_header) or {}
}
req := http.Request{
method: .get
url: '/api/protected'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 创建带自定义 header 的测试 Context
fn create_bearer_context_with_custom_header(header_name string, header_value string) hono.Context {
mut headers := http.new_header()
if header_value.len > 0 {
headers.add_custom(header_name, header_value) or {}
}
req := http.Request{
method: .get
url: '/api/protected'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 模拟 next 函数,返回一个简单的响应
fn bearer_mock_next(mut c hono.Context) http.Response {
c.status(200)
return c.text('OK')
}
// ============================================================================
// Property 9: Bearer Token Validation
// Feature: builtin-middleware, Property 9: Bearer Token Validation
// Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 4.10
//
// *For any* configured token(s), only requests with matching Bearer token in
// Authorization header SHALL be allowed to proceed.
// ============================================================================
fn test_property_9_bearer_token_validation() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
// Test 1: Valid single token should be allowed
for _ in 0 .. test_iterations / 5 {
valid_token := generate_random_token()
auth_header := 'Bearer ${valid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 200 {
println(' Single token test failed: valid token rejected, status ${response.status_code}')
return false
}
// Verify token is stored in context
stored_token := ctx.get('bearer_token') or { '' }
if stored_token != valid_token {
println(' Single token test failed: token not stored in context')
return false
}
}
// Test 2: Invalid single token should be rejected
for _ in 0 .. test_iterations / 5 {
valid_token := generate_random_token()
invalid_token := generate_random_token()
// Ensure tokens are different
if valid_token == invalid_token {
continue
}
auth_header := 'Bearer ${invalid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 401 {
println(' Invalid token test failed: invalid token accepted, status ${response.status_code}')
return false
}
// Verify WWW-Authenticate header is set
www_auth := response.header.get_custom('WWW-Authenticate') or { '' }
if !www_auth.starts_with('Bearer') {
println(' Invalid token test failed: WWW-Authenticate header not set correctly')
return false
}
}
// Test 3: Multiple tokens - any valid token should be allowed
for _ in 0 .. test_iterations / 5 {
tokens := [generate_random_token(), generate_random_token(), generate_random_token()]
// Pick a random valid token
valid_idx := rand.int_in_range(0, tokens.len) or { 0 }
auth_header := 'Bearer ${tokens[valid_idx]}'
mut ctx := create_bearer_context_with_auth(auth_header)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: tokens
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 200 {
println(' Multiple tokens test failed: valid token rejected, status ${response.status_code}')
return false
}
}
// Test 4: Missing Authorization header should be rejected
for _ in 0 .. test_iterations / 5 {
valid_token := generate_random_token()
mut ctx := create_bearer_context_with_auth('')
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 401 {
println(' Missing header test failed: request without auth accepted, status ${response.status_code}')
return false
}
// Verify WWW-Authenticate header is set
www_auth := response.header.get_custom('WWW-Authenticate') or { '' }
if !www_auth.starts_with('Bearer') {
println(' Missing header test failed: WWW-Authenticate header not set')
return false
}
}
// Test 5: Custom prefix should work correctly
for _ in 0 .. test_iterations / 5 {
valid_token := generate_random_token()
custom_prefix := generate_random_prefix()
auth_header := '${custom_prefix} ${valid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
prefix: custom_prefix
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 200 {
println(' Custom prefix test failed: valid token with custom prefix rejected')
return false
}
}
return true
}
// Test custom verify_token callback
fn test_property_9_custom_verify_callback() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for _ in 0 .. test_iterations / 2 {
// Generate a token that starts with "valid_"
valid_token := 'valid_${generate_random_token()}'
auth_header := 'Bearer ${valid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
// Custom verify function that only accepts tokens starting with "valid_"
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: ''
verify_token: fn (token string, c hono.Context) bool {
return token.starts_with('valid_')
}
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 200 {
println(' Custom verify callback test failed: valid token rejected')
return false
}
}
for _ in 0 .. test_iterations / 2 {
// Generate a token that does NOT start with "valid_"
invalid_token := 'invalid_${generate_random_token()}'
auth_header := 'Bearer ${invalid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
// Custom verify function that only accepts tokens starting with "valid_"
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: ''
verify_token: fn (token string, c hono.Context) bool {
return token.starts_with('valid_')
}
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 401 {
println(' Custom verify callback test failed: invalid token accepted')
return false
}
}
return true
}
// Test realm configuration
fn test_property_9_realm_configuration() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for _ in 0 .. test_iterations {
valid_token := generate_random_token()
invalid_token := generate_random_token()
realm := generate_random_realm()
// Ensure tokens are different
if valid_token == invalid_token {
continue
}
auth_header := 'Bearer ${invalid_token}'
mut ctx := create_bearer_context_with_auth(auth_header)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
realm: realm
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 401 {
println(' Realm test failed: invalid token accepted')
return false
}
// Verify WWW-Authenticate header includes realm
www_auth := response.header.get_custom('WWW-Authenticate') or { '' }
expected_www_auth := 'Bearer realm="${realm}"'
if www_auth != expected_www_auth {
println(' Realm test failed: expected "${expected_www_auth}", got "${www_auth}"')
return false
}
}
return true
}
// Test custom header name
fn test_property_9_custom_header_name() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
custom_headers := ['X-API-Key', 'X-Auth-Token', 'X-Access-Token']
for _ in 0 .. test_iterations {
valid_token := generate_random_token()
header_idx := rand.int_in_range(0, custom_headers.len) or { 0 }
custom_header := custom_headers[header_idx]
header_value := 'Bearer ${valid_token}'
mut ctx := create_bearer_context_with_custom_header(custom_header, header_value)
bearer_mw := hono.bearer_auth(hono.BearerAuthOptions{
token: valid_token
header_name: custom_header
})
response := bearer_mw(mut ctx, bearer_mock_next)
if response.status_code != 200 {
println(' Custom header name test failed: valid token rejected with custom header "${custom_header}"')
return false
}
}
return true
}
fn main() {
println('🚀 开始 Bearer Auth Middleware 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := BearerAuthTestStats{}
// 运行属性测试
// Feature: builtin-middleware, Property 9: Bearer Token Validation
// Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 4.10
stats.run_property_test('Property 9: Bearer Token Validation', test_property_9_bearer_token_validation)
stats.run_property_test('Property 9: Custom Verify Callback', test_property_9_custom_verify_callback)
stats.run_property_test('Property 9: Realm Configuration', test_property_9_realm_configuration)
stats.run_property_test('Property 9: Custom Header Name', test_property_9_custom_header_name)
// 打印测试总结
stats.print_summary()
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
fn main() {
println('=== 测试缓存内存泄漏修复 ===')
+81
View File
@@ -0,0 +1,81 @@
// 测试缓存命中率
module main
import meiseayoung.hono
import net.http
import time
fn main() {
println('=== 缓存命中率测试 ===')
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
user_id := c.params['id'] or { 'unknown' }
return c.json('{"user_id": "${user_id}"}')
})
// 获取 FastRouter 引用
mut router := app.fast_router
// 预热:先匹配一次让缓存生效
println('\n1. 预热阶段')
_ := router.match_route('GET', '/users/123')
// 获取初始统计
cache_size, cache_capacity := router.get_cache_stats()
println(' 缓存大小: ${cache_size}/${cache_capacity}')
// 测试相同路径的匹配
println('\n2. 测试相同路径 /users/123 匹配 1000 次')
sw := time.new_stopwatch()
for _ in 0 .. 1000 {
_ := router.match_route('GET', '/users/123')
}
elapsed := sw.elapsed()
println(' 耗时: ${elapsed.microseconds()}μs')
println(' 平均: ${elapsed.microseconds() / 1000}μs/次')
// 获取缓存统计
cache_size2, _ := router.get_cache_stats()
println(' 缓存大小: ${cache_size2}')
// 测试不同路径
println('\n3. 测试不同路径 /users/1 到 /users/1000')
sw2 := time.new_stopwatch()
for i in 1 .. 1001 {
_ := router.match_route('GET', '/users/${i}')
}
elapsed2 := sw2.elapsed()
println(' 耗时: ${elapsed2.microseconds()}μs')
println(' 平均: ${elapsed2.microseconds() / 1000}μs/次')
// 获取最终缓存统计
cache_size3, _ := router.get_cache_stats()
println(' 缓存大小: ${cache_size3}')
// 再次测试相同路径(应该命中缓存)
println('\n4. 再次测试 /users/500(应该命中缓存)')
sw3 := time.new_stopwatch()
for _ in 0 .. 1000 {
_ := router.match_route('GET', '/users/500')
}
elapsed3 := sw3.elapsed()
println(' 耗时: ${elapsed3.microseconds()}μs')
println(' 平均: ${elapsed3.microseconds() / 1000}μs/次')
// 详细统计
println('\n5. 详细统计')
stats := router.get_detailed_stats()
for key, value in stats {
println(' ${key}: ${value}')
}
println('\n=== 测试完成 ===')
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import strings
import net.http
+435
View File
@@ -0,0 +1,435 @@
import compress.gzip
import compress.zlib
import rand
import time
// Compression Middleware 属性测试
// Property-Based Testing for Compression functionality
//
// Note: These tests validate the compression round-trip properties
// using V's standard library compression functions, which are the
// same functions used by the compress middleware.
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Compression Middleware 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 生成随机字符串(用于测试压缩)
fn generate_random_string(min_len int, max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 \t\n!@#\$%^&*()_+-=[]{}|;:,.<>?'
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// 生成可压缩的文本(重复模式更容易压缩)
fn generate_compressible_text(min_len int, max_len int) string {
patterns := [
'Hello World! ',
'This is a test. ',
'Lorem ipsum dolor sit amet. ',
'The quick brown fox jumps over the lazy dog. ',
'AAAAAAAAAA',
'1234567890',
]
target_len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := ''
for result.len < target_len {
idx := rand.int_in_range(0, patterns.len) or { 0 }
result += patterns[idx]
}
if result.len > target_len {
return result[..target_len]
}
return result
}
// ============================================================================
// Property 10: Compression Round-Trip
// Feature: builtin-middleware, Property 10: Compression Round-Trip
// Validates: Requirements 5.1, 5.2, 5.6
//
// *For any* response body and supported encoding (gzip/deflate),
// compressing then decompressing SHALL produce the original body.
// ============================================================================
fn test_property_10_gzip_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_string(100, 5000)
// Test gzip round-trip
gzip_compressed := gzip.compress(original.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
println(' Iteration ${i}: Gzip compression failed: ${err}')
return false
}
gzip_decompressed := gzip.decompress(gzip_compressed) or {
println(' Iteration ${i}: Gzip decompression failed: ${err}')
return false
}
if gzip_decompressed.bytestr() != original {
println(' Iteration ${i}: Gzip round-trip mismatch')
println(' Original length: ${original.len}')
println(' Decompressed length: ${gzip_decompressed.len}')
return false
}
}
return true
}
fn test_property_10_deflate_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_string(100, 5000)
// Test deflate round-trip
deflate_compressed := zlib.compress(original.bytes()) or {
println(' Iteration ${i}: Deflate compression failed: ${err}')
return false
}
deflate_decompressed := zlib.decompress(deflate_compressed) or {
println(' Iteration ${i}: Deflate decompression failed: ${err}')
return false
}
if deflate_decompressed.bytestr() != original {
println(' Iteration ${i}: Deflate round-trip mismatch')
println(' Original length: ${original.len}')
println(' Decompressed length: ${deflate_decompressed.len}')
return false
}
}
return true
}
fn test_property_10_compressible_text_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for i in 0 .. test_iterations {
original := generate_compressible_text(500, 3000)
// Test gzip with compressible text
gzip_compressed := gzip.compress(original.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
println(' Iteration ${i}: Gzip compression failed')
return false
}
gzip_decompressed := gzip.decompress(gzip_compressed) or {
println(' Iteration ${i}: Gzip decompression failed: ${err}')
return false
}
if gzip_decompressed.bytestr() != original {
println(' Iteration ${i}: Gzip compressible text round-trip mismatch')
return false
}
// Test deflate with compressible text
deflate_compressed := zlib.compress(original.bytes()) or {
println(' Iteration ${i}: Deflate compression failed')
return false
}
deflate_decompressed := zlib.decompress(deflate_compressed) or {
println(' Iteration ${i}: Deflate decompression failed: ${err}')
return false
}
if deflate_decompressed.bytestr() != original {
println(' Iteration ${i}: Deflate compressible text round-trip mismatch')
return false
}
}
return true
}
// ============================================================================
// Property 11: Compression Threshold Enforcement
// Feature: builtin-middleware, Property 11: Compression Threshold Enforcement
// Validates: Requirements 5.7
//
// *For any* response body smaller than the configured threshold,
// the Compression middleware SHALL NOT compress the response.
//
// This test validates the threshold logic by checking that:
// 1. Small data (below threshold) should not be compressed
// 2. Large data (above threshold) can be compressed
// ============================================================================
fn test_property_11_threshold_logic() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
// Test various threshold values
thresholds := [512, 1024, 2048, 4096]
for threshold in thresholds {
for i in 0 .. 25 {
// Generate data smaller than threshold
small_data_len := rand.int_in_range(1, threshold) or { threshold / 2 }
small_data := generate_random_string(small_data_len, small_data_len + 1)
// Verify the data is indeed smaller than threshold
if small_data.len >= threshold {
continue // Skip if data is not smaller
}
// For small data, the middleware would skip compression
// We verify that the threshold check works correctly
should_compress := small_data.len >= threshold
if should_compress {
println(' Threshold logic error: small data (${small_data.len}) should not be compressed with threshold ${threshold}')
return false
}
// Generate data larger than threshold
large_data_len := rand.int_in_range(threshold + 100, threshold * 2) or { threshold + 500 }
large_data := generate_compressible_text(large_data_len, large_data_len + 1)
// Verify the data is indeed larger than threshold
if large_data.len < threshold {
continue // Skip if data is not larger
}
// For large data, compression should be attempted
should_compress_large := large_data.len >= threshold
if !should_compress_large {
println(' Threshold logic error: large data (${large_data.len}) should be compressed with threshold ${threshold}')
return false
}
// Verify compression actually works for large data
compressed := gzip.compress(large_data.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
println(' Iteration ${i}: Compression of large data failed')
return false
}
// Verify we can decompress it back
decompressed := gzip.decompress(compressed) or {
println(' Iteration ${i}: Decompression of large data failed')
return false
}
if decompressed.bytestr() != large_data {
println(' Iteration ${i}: Large data round-trip failed')
return false
}
}
}
return true
}
fn test_property_11_boundary_conditions() bool {
rand.seed([u32(time.now().unix()), u32(22222)])
threshold := 1024
for i in 0 .. test_iterations {
// Test data exactly at threshold boundary
// Data at exactly threshold should be compressed (>= threshold)
exact_data := generate_compressible_text(threshold, threshold + 1)
// Verify exact boundary
if exact_data.len < threshold {
continue
}
should_compress_exact := exact_data.len >= threshold
if !should_compress_exact {
println(' Iteration ${i}: Boundary condition error at exact threshold')
return false
}
// Test data just below threshold
below_threshold := threshold - 1
below_data := generate_random_string(below_threshold, below_threshold + 1)
if below_data.len >= threshold {
continue
}
should_compress_below := below_data.len >= threshold
if should_compress_below {
println(' Iteration ${i}: Boundary condition error below threshold')
return false
}
// Test data just above threshold
above_data := generate_compressible_text(threshold + 1, threshold + 100)
if above_data.len <= threshold {
continue
}
should_compress_above := above_data.len >= threshold
if !should_compress_above {
println(' Iteration ${i}: Boundary condition error above threshold')
return false
}
}
return true
}
// Unit tests for basic compression functionality
fn test_gzip_basic() {
original := 'Hello, World! This is a test string for compression.'
compressed := gzip.compress(original.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
assert false, 'Gzip compression failed: ${err}'
return
}
decompressed := gzip.decompress(compressed) or {
assert false, 'Gzip decompression failed: ${err}'
return
}
assert decompressed.bytestr() == original, 'Gzip round-trip failed'
}
fn test_deflate_basic() {
original := 'Hello, World! This is a test string for compression.'
compressed := zlib.compress(original.bytes()) or {
assert false, 'Deflate compression failed: ${err}'
return
}
decompressed := zlib.decompress(compressed) or {
assert false, 'Deflate decompression failed: ${err}'
return
}
assert decompressed.bytestr() == original, 'Deflate round-trip failed'
}
fn test_compression_reduces_size() {
// Highly compressible data
original := 'AAAAAAAAAA'.repeat(1000)
gzip_compressed := gzip.compress(original.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
assert false, 'Gzip compression failed'
return
}
deflate_compressed := zlib.compress(original.bytes()) or {
assert false, 'Deflate compression failed'
return
}
// Compressed size should be smaller than original
assert gzip_compressed.len < original.len, 'Gzip should reduce size for compressible data'
assert deflate_compressed.len < original.len, 'Deflate should reduce size for compressible data'
}
fn test_empty_data_compression() {
original := ''
// Gzip should handle empty data
gzip_compressed := gzip.compress(original.bytes(), gzip.CompressParams{
compression_level: 128
}) or {
assert false, 'Gzip compression of empty data failed: ${err}'
return
}
gzip_decompressed := gzip.decompress(gzip_compressed) or {
assert false, 'Gzip decompression of empty data failed: ${err}'
return
}
assert gzip_decompressed.bytestr() == original, 'Gzip empty data round-trip failed'
// Deflate should handle empty data
deflate_compressed := zlib.compress(original.bytes()) or {
assert false, 'Deflate compression of empty data failed: ${err}'
return
}
deflate_decompressed := zlib.decompress(deflate_compressed) or {
assert false, 'Deflate decompression of empty data failed: ${err}'
return
}
assert deflate_decompressed.bytestr() == original, 'Deflate empty data round-trip failed'
}
// Main function for running property tests
fn main() {
println('🚀 开始 Compression Middleware 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: builtin-middleware, Property 10: Compression Round-Trip
// Validates: Requirements 5.1, 5.2, 5.6
stats.run_property_test('Property 10: Gzip Round-Trip', test_property_10_gzip_roundtrip)
stats.run_property_test('Property 10: Deflate Round-Trip', test_property_10_deflate_roundtrip)
stats.run_property_test('Property 10: Compressible Text Round-Trip', test_property_10_compressible_text_roundtrip)
// Feature: builtin-middleware, Property 11: Compression Threshold Enforcement
// Validates: Requirements 5.7
stats.run_property_test('Property 11: Threshold Logic', test_property_11_threshold_logic)
stats.run_property_test('Property 11: Boundary Conditions', test_property_11_boundary_conditions)
// Print test summary
stats.print_summary()
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import os
fn test_default_config() {
+237
View File
@@ -0,0 +1,237 @@
import meiseayoung.hono
import net.http
// Context Store 功能测试
// 测试 Context 的 store 字段和 get/set/get_client_ip 方法
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=== Context Store 测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个测试失败')
}
}
// 创建测试用的 Context
fn create_test_context() hono.Context {
req := http.Request{
method: .get
url: '/test'
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 测试 1: 基本的 set 和 get 操作
fn test_basic_set_get() bool {
mut ctx := create_test_context()
// 设置值
ctx.set('user_id', '12345')
ctx.set('role', 'admin')
// 获取值
if user_id := ctx.get('user_id') {
if user_id != '12345' {
return false
}
} else {
return false
}
if role := ctx.get('role') {
if role != 'admin' {
return false
}
} else {
return false
}
return true
}
// 测试 2: 获取不存在的 key 返回 none
fn test_get_nonexistent_key() bool {
ctx := create_test_context()
// 获取不存在的 key
if _ := ctx.get('nonexistent') {
return false // 应该返回 none
}
return true
}
// 测试 3: 覆盖已存在的值
fn test_overwrite_value() bool {
mut ctx := create_test_context()
// 设置初始值
ctx.set('key', 'value1')
// 覆盖值
ctx.set('key', 'value2')
// 验证新值
if val := ctx.get('key') {
return val == 'value2'
}
return false
}
// 测试 4: 空字符串值
fn test_empty_string_value() bool {
mut ctx := create_test_context()
// 设置空字符串
ctx.set('empty', '')
// 获取空字符串
if val := ctx.get('empty') {
return val == ''
}
return false
}
// 测试 5: 多个键值对
fn test_multiple_keys() bool {
mut ctx := create_test_context()
// 设置多个值
for i in 0 .. 10 {
ctx.set('key${i}', 'value${i}')
}
// 验证所有值
for i in 0 .. 10 {
if val := ctx.get('key${i}') {
if val != 'value${i}' {
return false
}
} else {
return false
}
}
return true
}
// 测试 6: get_client_ip 默认返回值
fn test_get_client_ip_default() bool {
ctx := create_test_context()
// 没有设置任何 IP 相关头时,应返回默认值
ip := ctx.get_client_ip()
return ip == '127.0.0.1'
}
// 测试 7: get_client_ip 从 X-Forwarded-For 获取
fn test_get_client_ip_forwarded_for() bool {
mut headers := http.new_header()
headers.add_custom('X-Forwarded-For', '192.168.1.100, 10.0.0.1') or { return false }
req := http.Request{
method: .get
url: '/test'
header: headers
}
ctx := hono.Context.new(req, map[string]string{}, map[string]string{}, '')
// 应该返回第一个 IP
ip := ctx.get_client_ip()
return ip == '192.168.1.100'
}
// 测试 8: get_client_ip 从 X-Real-IP 获取
fn test_get_client_ip_real_ip() bool {
mut headers := http.new_header()
headers.add_custom('X-Real-IP', '10.20.30.40') or { return false }
req := http.Request{
method: .get
url: '/test'
header: headers
}
ctx := hono.Context.new(req, map[string]string{}, map[string]string{}, '')
ip := ctx.get_client_ip()
return ip == '10.20.30.40'
}
// 测试 9: X-Forwarded-For 优先于 X-Real-IP
fn test_get_client_ip_priority() bool {
mut headers := http.new_header()
headers.add_custom('X-Forwarded-For', '1.2.3.4') or { return false }
headers.add_custom('X-Real-IP', '5.6.7.8') or { return false }
req := http.Request{
method: .get
url: '/test'
header: headers
}
ctx := hono.Context.new(req, map[string]string{}, map[string]string{}, '')
// X-Forwarded-For 应该优先
ip := ctx.get_client_ip()
return ip == '1.2.3.4'
}
// 测试 10: store 初始化为空
fn test_store_initialized_empty() bool {
ctx := create_test_context()
// 新创建的 Context 的 store 应该为空
// 尝试获取任何 key 都应该返回 none
if _ := ctx.get('any_key') {
return false
}
return true
}
fn main() {
println('🚀 开始 Context Store 功能测试...\n')
mut stats := TestStats{}
// 运行所有测试
stats.run_test('基本 set/get 操作', test_basic_set_get)
stats.run_test('获取不存在的 key', test_get_nonexistent_key)
stats.run_test('覆盖已存在的值', test_overwrite_value)
stats.run_test('空字符串值', test_empty_string_value)
stats.run_test('多个键值对', test_multiple_keys)
stats.run_test('get_client_ip 默认值', test_get_client_ip_default)
stats.run_test('get_client_ip X-Forwarded-For', test_get_client_ip_forwarded_for)
stats.run_test('get_client_ip X-Real-IP', test_get_client_ip_real_ip)
stats.run_test('X-Forwarded-For 优先级', test_get_client_ip_priority)
stats.run_test('store 初始化为空', test_store_initialized_empty)
// 打印测试总结
stats.print_summary()
}
+320
View File
@@ -0,0 +1,320 @@
import meiseayoung.hono
import net.http
// Cookie Helper 功能测试
// 测试 Cookie 的 get/set/delete 和签名功能
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=== Cookie Helper 测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个测试失败')
}
}
// 创建带 Cookie 头的测试 Context
fn create_test_context_with_cookies(cookie_header string) hono.Context {
mut headers := http.new_header()
if cookie_header.len > 0 {
headers.add_custom('Cookie', cookie_header) or {}
}
req := http.Request{
method: .get
url: '/test'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 创建空的测试 Context
fn create_empty_context() hono.Context {
req := http.Request{
method: .get
url: '/test'
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 测试 1: 获取单个 Cookie
fn test_get_single_cookie() bool {
ctx := create_test_context_with_cookies('session_id=abc123')
if value := hono.get_cookie(ctx, 'session_id') {
return value == 'abc123'
}
return false
}
// 测试 2: 获取不存在的 Cookie 返回 none
fn test_get_nonexistent_cookie() bool {
ctx := create_test_context_with_cookies('session_id=abc123')
if _ := hono.get_cookie(ctx, 'nonexistent') {
return false // 应该返回 none
}
return true
}
// 测试 3: 获取多个 Cookie
fn test_get_multiple_cookies() bool {
ctx := create_test_context_with_cookies('session_id=abc123; user_id=456; theme=dark')
session := hono.get_cookie(ctx, 'session_id') or { return false }
user := hono.get_cookie(ctx, 'user_id') or { return false }
theme := hono.get_cookie(ctx, 'theme') or { return false }
return session == 'abc123' && user == '456' && theme == 'dark'
}
// 测试 4: 获取所有 Cookie
fn test_get_all_cookies() bool {
ctx := create_test_context_with_cookies('a=1; b=2; c=3')
cookies := hono.get_all_cookies(ctx)
return cookies.len == 3 &&
cookies['a'] == '1' &&
cookies['b'] == '2' &&
cookies['c'] == '3'
}
// 测试 5: 空 Cookie 头返回空 map
fn test_get_all_cookies_empty() bool {
ctx := create_empty_context()
cookies := hono.get_all_cookies(ctx)
return cookies.len == 0
}
// 测试 6: 设置 Cookie
fn test_set_cookie_basic() bool {
mut ctx := create_empty_context()
hono.set_cookie(mut ctx, 'session', 'xyz789')
// 检查 Set-Cookie 头是否被设置
if set_cookie := ctx.headers['Set-Cookie'] {
return set_cookie.contains('session=xyz789')
}
return false
}
// 测试 7: 设置 Cookie 带选项
fn test_set_cookie_with_options() bool {
mut ctx := create_empty_context()
hono.set_cookie(mut ctx, 'token', 'secret123', hono.CookieOptions{
path: '/api'
http_only: true
secure: true
max_age: 3600
same_site: .strict
})
if set_cookie := ctx.headers['Set-Cookie'] {
return set_cookie.contains('token=secret123') &&
set_cookie.contains('Path=/api') &&
set_cookie.contains('HttpOnly') &&
set_cookie.contains('Secure') &&
set_cookie.contains('Max-Age=3600') &&
set_cookie.contains('SameSite=Strict')
}
return false
}
// 测试 8: 删除 Cookie
fn test_delete_cookie() bool {
mut ctx := create_empty_context()
hono.delete_cookie(mut ctx, 'session')
if set_cookie := ctx.headers['Set-Cookie'] {
// 删除 Cookie 应该设置 Max-Age=0 或过期时间
return set_cookie.contains('session=') &&
(set_cookie.contains('Max-Age=0') || set_cookie.contains('Expires='))
}
return false
}
// 测试 9: Cookie 值带空格
fn test_cookie_with_spaces() bool {
ctx := create_test_context_with_cookies('name=John Doe')
if value := hono.get_cookie(ctx, 'name') {
return value == 'John Doe'
}
return false
}
// 测试 10: Cookie 值带引号
fn test_cookie_with_quotes() bool {
ctx := create_test_context_with_cookies('data="hello world"')
if value := hono.get_cookie(ctx, 'data') {
return value == 'hello world'
}
return false
}
// 测试 11: 签名 Cookie 设置和获取
fn test_signed_cookie_roundtrip() bool {
mut ctx := create_empty_context()
secret := 'my-secret-key-12345'
// 设置签名 Cookie
hono.set_signed_cookie(mut ctx, 'auth', 'user123', secret) or {
println('Failed to set signed cookie: ${err}')
return false
}
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or { return false }
// 提取 Cookie 值(格式: auth=value.signature; ...)
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or { return false }
cookie_value = name_value[eq_pos + 1..]
}
// 创建一个带有这个 Cookie 的新 Context 来验证
verify_ctx := create_test_context_with_cookies('auth=${cookie_value}')
// 验证签名 Cookie
if value := hono.get_signed_cookie(verify_ctx, 'auth', secret) {
return value == 'user123'
} else {
println('Failed to get signed cookie: ${err}')
return false
}
}
// 测试 12: 签名 Cookie 篡改检测
fn test_signed_cookie_tamper_detection() bool {
mut ctx := create_empty_context()
secret := 'my-secret-key-12345'
// 设置签名 Cookie
hono.set_signed_cookie(mut ctx, 'auth', 'user123', secret) or { return false }
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or { return false }
// 提取 Cookie 值
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or { return false }
cookie_value = name_value[eq_pos + 1..]
}
// 篡改 Cookie 值
tampered_value := 'tampered' + cookie_value[8..] // 修改值部分
// 创建带篡改 Cookie 的 Context
verify_ctx := create_test_context_with_cookies('auth=${tampered_value}')
// 验证应该失败
if _ := hono.get_signed_cookie(verify_ctx, 'auth', secret) {
return false // 不应该成功
}
return true // 验证失败是预期的
}
// 测试 13: 签名 Cookie 错误密钥
fn test_signed_cookie_wrong_secret() bool {
mut ctx := create_empty_context()
// 使用一个密钥设置
hono.set_signed_cookie(mut ctx, 'auth', 'user123', 'secret1') or { return false }
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or { return false }
// 提取 Cookie 值
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or { return false }
cookie_value = name_value[eq_pos + 1..]
}
// 创建带 Cookie 的 Context
verify_ctx := create_test_context_with_cookies('auth=${cookie_value}')
// 使用不同的密钥验证应该失败
if _ := hono.get_signed_cookie(verify_ctx, 'auth', 'secret2') {
return false // 不应该成功
}
return true // 验证失败是预期的
}
// 测试 14: 空密钥应该返回错误
fn test_signed_cookie_empty_secret() bool {
mut ctx := create_empty_context()
// 空密钥应该返回错误
if _ := hono.set_signed_cookie(mut ctx, 'auth', 'value', '') {
return false // 不应该成功
}
return true
}
fn main() {
println('🚀 开始 Cookie Helper 功能测试...\n')
mut stats := TestStats{}
// 运行所有测试
stats.run_test('获取单个 Cookie', test_get_single_cookie)
stats.run_test('获取不存在的 Cookie', test_get_nonexistent_cookie)
stats.run_test('获取多个 Cookie', test_get_multiple_cookies)
stats.run_test('获取所有 Cookie', test_get_all_cookies)
stats.run_test('空 Cookie 头返回空 map', test_get_all_cookies_empty)
stats.run_test('设置 Cookie 基本功能', test_set_cookie_basic)
stats.run_test('设置 Cookie 带选项', test_set_cookie_with_options)
stats.run_test('删除 Cookie', test_delete_cookie)
stats.run_test('Cookie 值带空格', test_cookie_with_spaces)
stats.run_test('Cookie 值带引号', test_cookie_with_quotes)
stats.run_test('签名 Cookie 往返一致性', test_signed_cookie_roundtrip)
stats.run_test('签名 Cookie 篡改检测', test_signed_cookie_tamper_detection)
stats.run_test('签名 Cookie 错误密钥', test_signed_cookie_wrong_secret)
stats.run_test('签名 Cookie 空密钥错误', test_signed_cookie_empty_secret)
// 打印测试总结
stats.print_summary()
}
+320
View File
@@ -0,0 +1,320 @@
import meiseayoung.hono
import net.http
import rand
import time
// Cookie Helper 属性测试
// Property-Based Testing for Cookie functionality
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Cookie Helper 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 创建带 Cookie 头的测试 Context
fn create_context_with_cookies(cookie_header string) hono.Context {
mut headers := http.new_header()
if cookie_header.len > 0 {
headers.add_custom('Cookie', cookie_header) or {}
}
req := http.Request{
method: .get
url: '/test'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 创建空的测试 Context
fn create_empty_context() hono.Context {
req := http.Request{
method: .get
url: '/test'
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 生成随机的 Cookie 名称(只包含有效字符)
fn generate_random_cookie_name() string {
valid_chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
len := rand.int_in_range(1, 20) or { 5 }
mut name := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, valid_chars.len) or { 0 }
name += valid_chars[idx].ascii_str()
}
return name
}
// 生成随机的 Cookie 值(不包含分号和等号)
fn generate_random_cookie_value() string {
// 使用安全的字符集,避免特殊字符
valid_chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, 50) or { 10 }
mut value := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, valid_chars.len) or { 0 }
value += valid_chars[idx].ascii_str()
}
return value
}
// 生成随机的密钥
fn generate_random_secret() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
len := rand.int_in_range(16, 64) or { 32 }
mut secret := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
secret += chars[idx].ascii_str()
}
return secret
}
// ============================================================================
// Property 3: Cookie Round-Trip Consistency
// Feature: builtin-middleware, Property 3: Cookie Round-Trip Consistency
// Validates: Requirements 2.1, 2.3
//
// *For any* cookie name and value, calling set_cookie followed by get_cookie
// on the same name SHALL return the original value.
// ============================================================================
fn test_property_3_cookie_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
name := generate_random_cookie_name()
value := generate_random_cookie_value()
// 设置 Cookie
mut ctx := create_empty_context()
hono.set_cookie(mut ctx, name, value)
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or {
println(' Iteration ${i}: Failed to get Set-Cookie header')
return false
}
// 提取 Cookie 值
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or {
println(' Iteration ${i}: Invalid cookie format')
return false
}
cookie_value = name_value[eq_pos + 1..]
}
// 创建带 Cookie 的 Context 来验证
verify_ctx := create_context_with_cookies('${name}=${cookie_value}')
// 获取 Cookie 并验证
retrieved := hono.get_cookie(verify_ctx, name) or {
println(' Iteration ${i}: Failed to get cookie')
return false
}
if retrieved != value {
println(' Iteration ${i}: Value mismatch - expected "${value}", got "${retrieved}"')
return false
}
}
return true
}
// ============================================================================
// Property 4: Signed Cookie Round-Trip
// Feature: builtin-middleware, Property 4: Signed Cookie Round-Trip
// Validates: Requirements 2.7, 2.8
//
// *For any* cookie name, value, and secret, calling set_signed_cookie followed
// by get_signed_cookie with the same secret SHALL return the original value.
// ============================================================================
fn test_property_4_signed_cookie_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
name := generate_random_cookie_name()
value := generate_random_cookie_value()
secret := generate_random_secret()
// 设置签名 Cookie
mut ctx := create_empty_context()
hono.set_signed_cookie(mut ctx, name, value, secret) or {
println(' Iteration ${i}: Failed to set signed cookie: ${err}')
return false
}
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or {
println(' Iteration ${i}: Failed to get Set-Cookie header')
return false
}
// 提取 Cookie 值(包含签名)
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or {
println(' Iteration ${i}: Invalid cookie format')
return false
}
cookie_value = name_value[eq_pos + 1..]
}
// 创建带签名 Cookie 的 Context 来验证
verify_ctx := create_context_with_cookies('${name}=${cookie_value}')
// 获取并验证签名 Cookie
retrieved := hono.get_signed_cookie(verify_ctx, name, secret) or {
println(' Iteration ${i}: Failed to get signed cookie: ${err}')
return false
}
if retrieved != value {
println(' Iteration ${i}: Value mismatch - expected "${value}", got "${retrieved}"')
return false
}
}
return true
}
// ============================================================================
// Property 5: Signed Cookie Tamper Detection
// Feature: builtin-middleware, Property 5: Signed Cookie Tamper Detection
// Validates: Requirements 2.9
//
// *For any* signed cookie, modifying the cookie value or signature SHALL cause
// get_signed_cookie to return an error.
// ============================================================================
fn test_property_5_signed_cookie_tamper_detection() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for i in 0 .. test_iterations {
name := generate_random_cookie_name()
value := generate_random_cookie_value()
secret := generate_random_secret()
// 设置签名 Cookie
mut ctx := create_empty_context()
hono.set_signed_cookie(mut ctx, name, value, secret) or {
println(' Iteration ${i}: Failed to set signed cookie: ${err}')
return false
}
// 获取 Set-Cookie 头中的值
set_cookie_header := ctx.headers['Set-Cookie'] or {
println(' Iteration ${i}: Failed to get Set-Cookie header')
return false
}
// 提取 Cookie 值(包含签名)
mut cookie_value := ''
parts := set_cookie_header.split(';')
if parts.len > 0 {
name_value := parts[0].trim_space()
eq_pos := name_value.index('=') or {
println(' Iteration ${i}: Invalid cookie format')
return false
}
cookie_value = name_value[eq_pos + 1..]
}
// 篡改 Cookie 值(修改第一个字符)
if cookie_value.len > 1 {
// 找到签名分隔符
dot_pos := cookie_value.last_index('.') or {
println(' Iteration ${i}: No signature separator found')
return false
}
// 篡改值部分
original_value_part := cookie_value[..dot_pos]
signature_part := cookie_value[dot_pos..]
// 修改值的第一个字符
mut tampered_value := ''
if original_value_part.len > 0 {
first_char := original_value_part[0]
new_char := if first_char == `a` { 'b' } else { 'a' }
tampered_value = new_char + original_value_part[1..] + signature_part
} else {
tampered_value = 'x' + signature_part
}
// 创建带篡改 Cookie 的 Context
verify_ctx := create_context_with_cookies('${name}=${tampered_value}')
// 验证应该失败
if _ := hono.get_signed_cookie(verify_ctx, name, secret) {
println(' Iteration ${i}: Tampered cookie was accepted (should have been rejected)')
return false
}
}
}
return true
}
fn main() {
println('🚀 开始 Cookie Helper 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// 运行属性测试
// Feature: builtin-middleware, Property 3: Cookie Round-Trip Consistency
// Validates: Requirements 2.1, 2.3
stats.run_property_test('Property 3: Cookie Round-Trip Consistency', test_property_3_cookie_roundtrip)
// Feature: builtin-middleware, Property 4: Signed Cookie Round-Trip
// Validates: Requirements 2.7, 2.8
stats.run_property_test('Property 4: Signed Cookie Round-Trip', test_property_4_signed_cookie_roundtrip)
// Feature: builtin-middleware, Property 5: Signed Cookie Tamper Detection
// Validates: Requirements 2.9
stats.run_property_test('Property 5: Signed Cookie Tamper Detection', test_property_5_signed_cookie_tamper_detection)
// 打印测试总结
stats.print_summary()
}
+341
View File
@@ -0,0 +1,341 @@
import meiseayoung.hono
import net.http
import rand
import time
// CORS Middleware 属性测试
// Property-Based Testing for CORS functionality
const test_iterations = 100
struct CorsTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats CorsTestStats) run_property_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 CorsTestStats) print_summary() {
println('\n=== CORS Middleware 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 创建带 Origin 头的测试 Context
fn create_cors_context_with_origin(origin string, method http.Method) hono.Context {
mut headers := http.new_header()
if origin.len > 0 {
headers.add_custom('Origin', origin) or {}
}
req := http.Request{
method: method
url: '/test'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 创建带 Origin 和 Access-Control-Request-Headers 的预检请求 Context
fn create_cors_preflight_context(origin string, request_headers string) hono.Context {
mut headers := http.new_header()
if origin.len > 0 {
headers.add_custom('Origin', origin) or {}
}
if request_headers.len > 0 {
headers.add_custom('Access-Control-Request-Headers', request_headers) or {}
}
req := http.Request{
method: .options
url: '/test'
header: headers
}
return hono.Context.new(req, map[string]string{}, map[string]string{}, '')
}
// 生成随机的域名
fn generate_cors_random_origin() string {
protocols := ['http', 'https']
domains := ['example', 'test', 'api', 'app', 'web', 'service', 'backend', 'frontend']
tlds := ['com', 'org', 'net', 'io', 'dev', 'app']
protocol := protocols[rand.int_in_range(0, protocols.len) or { 0 }]
domain := domains[rand.int_in_range(0, domains.len) or { 0 }]
tld := tlds[rand.int_in_range(0, tlds.len) or { 0 }]
// 有时添加端口
port := if rand.int_in_range(0, 3) or { 0 } == 0 {
':${rand.int_in_range(3000, 9000) or { 8080 }}'
} else {
''
}
return '${protocol}://${domain}.${tld}${port}'
}
// 生成随机的 HTTP 方法
fn generate_cors_random_method() http.Method {
methods := [http.Method.get, http.Method.post, http.Method.put, http.Method.delete, http.Method.patch]
return methods[rand.int_in_range(0, methods.len) or { 0 }]
}
// 生成随机的请求头列表
fn generate_cors_random_headers() []string {
all_headers := ['Content-Type', 'Authorization', 'X-Custom-Header', 'X-Request-ID', 'Accept', 'Cache-Control']
count := rand.int_in_range(1, all_headers.len) or { 2 }
mut result := []string{}
for i in 0 .. count {
result << all_headers[i]
}
return result
}
// 模拟 next 函数,返回一个简单的响应
fn cors_mock_next(mut c hono.Context) http.Response {
return c.text('OK')
}
// ============================================================================
// Property 1: CORS Origin Header Consistency
// Feature: builtin-middleware, Property 1: CORS Origin Header Consistency
// Validates: Requirements 1.1, 1.3, 1.4, 1.5, 1.6
//
// *For any* request with an Origin header and any CORS configuration, the
// Access-Control-Allow-Origin response header SHALL match the configured
// origin policy (wildcard "*", specific domain, or callback result).
// ============================================================================
fn test_property_1_cors_origin_header_consistency() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
// Test 1: Wildcard origin ("*") should allow all origins
for _ in 0 .. test_iterations / 4 {
origin := generate_cors_random_origin()
mut ctx := create_cors_context_with_origin(origin, generate_cors_random_method())
cors_mw := hono.cors()
_ := cors_mw(mut ctx, cors_mock_next)
allowed_origin := ctx.headers['Access-Control-Allow-Origin'] or { '' }
if allowed_origin != '*' {
println(' Wildcard test failed: expected "*", got "${allowed_origin}"')
return false
}
}
// Test 2: Specific domain should only allow that domain
for _ in 0 .. test_iterations / 4 {
allowed_domain := generate_cors_random_origin()
request_origin := generate_cors_random_origin()
mut ctx := create_cors_context_with_origin(request_origin, generate_cors_random_method())
cors_mw := hono.cors(hono.CorsOptions{
origin: allowed_domain
})
_ := cors_mw(mut ctx, cors_mock_next)
result_origin := ctx.headers['Access-Control-Allow-Origin'] or { '' }
if request_origin == allowed_domain {
// Should be allowed
if result_origin != request_origin {
println(' Specific domain test failed: expected "${request_origin}", got "${result_origin}"')
return false
}
} else {
// Should not be allowed (no header set)
if result_origin != '' {
println(' Specific domain test failed: expected empty, got "${result_origin}"')
return false
}
}
}
// Test 3: Array of domains should allow any domain in the array
for _ in 0 .. test_iterations / 4 {
allowed_domains := [generate_cors_random_origin(), generate_cors_random_origin(), generate_cors_random_origin()]
// Test with an allowed domain
allowed_idx := rand.int_in_range(0, allowed_domains.len) or { 0 }
request_origin := allowed_domains[allowed_idx]
mut ctx := create_cors_context_with_origin(request_origin, generate_cors_random_method())
cors_mw := hono.cors(hono.CorsOptions{
origin: allowed_domains
})
_ := cors_mw(mut ctx, cors_mock_next)
result_origin := ctx.headers['Access-Control-Allow-Origin'] or { '' }
if result_origin != request_origin {
println(' Array domain test failed: expected "${request_origin}", got "${result_origin}"')
return false
}
}
// Test 4: Callback function should use callback result
for _ in 0 .. test_iterations / 4 {
request_origin := generate_cors_random_origin()
mut ctx := create_cors_context_with_origin(request_origin, generate_cors_random_method())
// Callback that returns the origin if it starts with "https"
cors_mw := hono.cors(hono.CorsOptions{
origin: fn (origin string, c hono.Context) string {
if origin.starts_with('https') {
return origin
}
return ''
}
})
_ := cors_mw(mut ctx, cors_mock_next)
result_origin := ctx.headers['Access-Control-Allow-Origin'] or { '' }
if request_origin.starts_with('https') {
if result_origin != request_origin {
println(' Callback test failed: expected "${request_origin}", got "${result_origin}"')
return false
}
} else {
if result_origin != '' {
println(' Callback test failed: expected empty for non-https, got "${result_origin}"')
return false
}
}
}
return true
}
// ============================================================================
// Property 2: CORS Preflight Response
// Feature: builtin-middleware, Property 2: CORS Preflight Response
// Validates: Requirements 1.2, 1.7, 1.8, 1.9, 1.10, 1.11
//
// *For any* OPTIONS preflight request with Origin header, the CORS middleware
// SHALL return status 204 with all configured CORS headers (Allow-Methods,
// Allow-Headers, Expose-Headers, Max-Age, Credentials).
// ============================================================================
fn test_property_2_cors_preflight_response() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for _ in 0 .. test_iterations {
origin := generate_cors_random_origin()
allow_methods := ['GET', 'POST', 'PUT', 'DELETE']
allow_headers := generate_cors_random_headers()
expose_headers := ['X-Custom-Response', 'X-Request-ID']
max_age := rand.int_in_range(60, 86400) or { 3600 }
credentials := rand.int_in_range(0, 2) or { 0 } == 1
mut ctx := create_cors_preflight_context(origin, allow_headers.join(', '))
cors_mw := hono.cors(hono.CorsOptions{
origin: '*'
allow_methods: allow_methods
allow_headers: allow_headers
expose_headers: expose_headers
max_age: max_age
credentials: credentials
})
response := cors_mw(mut ctx, cors_mock_next)
// Check status code is 204
if response.status_code != 204 {
println(' Preflight status test failed: expected 204, got ${response.status_code}')
return false
}
// Check Access-Control-Allow-Origin
allowed_origin := ctx.headers['Access-Control-Allow-Origin'] or { '' }
if allowed_origin != '*' {
println(' Preflight origin test failed: expected "*", got "${allowed_origin}"')
return false
}
// Check Access-Control-Allow-Methods
allowed_methods := ctx.headers['Access-Control-Allow-Methods'] or { '' }
if allowed_methods != allow_methods.join(', ') {
println(' Preflight methods test failed: expected "${allow_methods.join(', ')}", got "${allowed_methods}"')
return false
}
// Check Access-Control-Allow-Headers
result_headers := ctx.headers['Access-Control-Allow-Headers'] or { '' }
if result_headers != allow_headers.join(', ') {
println(' Preflight headers test failed: expected "${allow_headers.join(', ')}", got "${result_headers}"')
return false
}
// Check Access-Control-Expose-Headers
result_expose := ctx.headers['Access-Control-Expose-Headers'] or { '' }
if result_expose != expose_headers.join(', ') {
println(' Preflight expose headers test failed: expected "${expose_headers.join(', ')}", got "${result_expose}"')
return false
}
// Check Access-Control-Max-Age
result_max_age := ctx.headers['Access-Control-Max-Age'] or { '' }
if result_max_age != max_age.str() {
println(' Preflight max-age test failed: expected "${max_age}", got "${result_max_age}"')
return false
}
// Check Access-Control-Allow-Credentials
result_credentials := ctx.headers['Access-Control-Allow-Credentials'] or { '' }
if credentials {
if result_credentials != 'true' {
println(' Preflight credentials test failed: expected "true", got "${result_credentials}"')
return false
}
} else {
if result_credentials != '' {
println(' Preflight credentials test failed: expected empty, got "${result_credentials}"')
return false
}
}
}
return true
}
fn main() {
println('🚀 开始 CORS Middleware 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := CorsTestStats{}
// 运行属性测试
// Feature: builtin-middleware, Property 1: CORS Origin Header Consistency
// Validates: Requirements 1.1, 1.3, 1.4, 1.5, 1.6
stats.run_property_test('Property 1: CORS Origin Header Consistency', test_property_1_cors_origin_header_consistency)
// Feature: builtin-middleware, Property 2: CORS Preflight Response
// Validates: Requirements 1.2, 1.7, 1.8, 1.9, 1.10, 1.11
stats.run_property_test('Property 2: CORS Preflight Response', test_property_2_cors_preflight_response)
// 打印测试总结
stats.print_summary()
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
fn main() {
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
import time
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+814
View File
@@ -0,0 +1,814 @@
module main
import rand
import time
// ============================================================================
// Property 8: Error Classification
// Feature: v-hono-upload-integration, Property 8: Error Classification
// Validates: Requirements 9.4
//
// *For any* storage operation failure, the error should be correctly classified
// as retryable (network timeout, temporary unavailable) or non-retryable
// (invalid credentials, file not found).
// ============================================================================
const test_iterations = 100
// ============================================================================
// Type definitions (copied from storage_errors.v for standalone testing)
// ============================================================================
// 存储错误类型枚举
enum StorageErrorKind {
// 可重试错误 - 网络和临时性问题
network_timeout
service_unavailable
rate_limited
connection_reset
temporary_failure
// 不可重试错误 - 配置和权限问题
invalid_credentials
access_denied
bucket_not_found
object_not_found
invalid_object_key
quota_exceeded
invalid_config
bucket_not_empty
bucket_already_exists
object_already_exists
invalid_range
checksum_mismatch
// 其他错误
unknown
}
// 重试尝试记录
struct RetryAttempt {
pub:
attempt_number int
timestamp i64
error_message string
delay_ms int
}
// 存储错误结构
struct StorageError {
pub:
kind StorageErrorKind
message string
provider string
operation string
http_status int
retry_count int
details map[string]string
pub mut:
retry_history []RetryAttempt
}
// 判断错误是否可重试
fn (e StorageError) is_retryable() bool {
return e.kind in [
.network_timeout,
.service_unavailable,
.rate_limited,
.connection_reset,
.temporary_failure,
]
}
// 获取建议的重试延迟(毫秒)
fn (e StorageError) suggested_retry_delay() int {
base_delay := match e.kind {
.rate_limited { 5000 }
.service_unavailable { 2000 }
.network_timeout { 1000 }
.connection_reset { 500 }
.temporary_failure { 1000 }
else { 0 }
}
return base_delay
}
// 获取错误类别描述
fn (e StorageError) category() string {
if e.is_retryable() {
return 'retryable'
}
return 'non_retryable'
}
// 实现 IError 接口
fn (e StorageError) msg() string {
return '${e.provider}/${e.operation}: ${e.message} (kind: ${e.kind}, http_status: ${e.http_status})'
}
// 添加重试记录
fn (mut e StorageError) add_retry_attempt(attempt_number int, error_message string, delay_ms int) {
e.retry_history << RetryAttempt{
attempt_number: attempt_number
timestamp: time.now().unix()
error_message: error_message
delay_ms: delay_ms
}
}
// 获取重试历史摘要
fn (e StorageError) retry_summary() string {
if e.retry_history.len == 0 {
return 'No retry attempts'
}
mut summary := 'Retry attempts: ${e.retry_history.len}\n'
for attempt in e.retry_history {
summary += ' Attempt ${attempt.attempt_number}: ${attempt.error_message} (delay: ${attempt.delay_ms}ms)\n'
}
return summary
}
// 创建存储错误的辅助函数
fn new_storage_error(kind StorageErrorKind, message string, provider string, operation string) StorageError {
return StorageError{
kind: kind
message: message
provider: provider
operation: operation
http_status: 0
retry_count: 0
details: map[string]string{}
retry_history: []RetryAttempt{}
}
}
// ============================================================================
// HTTP 状态码到错误类型的映射
// ============================================================================
fn error_kind_from_http_status(status int) StorageErrorKind {
return match status {
400 { StorageErrorKind.invalid_object_key }
401 { StorageErrorKind.invalid_credentials }
403 { StorageErrorKind.access_denied }
404 { StorageErrorKind.object_not_found }
409 { StorageErrorKind.bucket_already_exists }
413 { StorageErrorKind.quota_exceeded }
416 { StorageErrorKind.invalid_range }
429 { StorageErrorKind.rate_limited }
500 { StorageErrorKind.service_unavailable }
502 { StorageErrorKind.service_unavailable }
503 { StorageErrorKind.service_unavailable }
504 { StorageErrorKind.network_timeout }
else { StorageErrorKind.unknown }
}
}
// ============================================================================
// 提供者特定的错误映射
// ============================================================================
fn map_s3_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'AccountProblem' { StorageErrorKind.access_denied }
'AllAccessDisabled' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketAlreadyOwnedByYou' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'CredentialsNotSupported' { StorageErrorKind.invalid_credentials }
'ExpiredToken' { StorageErrorKind.invalid_credentials }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'InvalidSecurity' { StorageErrorKind.invalid_credentials }
'InvalidToken' { StorageErrorKind.invalid_credentials }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'SlowDown' { StorageErrorKind.rate_limited }
'TemporaryRedirect' { StorageErrorKind.temporary_failure }
'TokenRefreshRequired' { StorageErrorKind.invalid_credentials }
else { error_kind_from_http_status(http_status) }
}
}
fn map_aliyun_oss_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'EntityTooLarge' { StorageErrorKind.quota_exceeded }
'EntityTooSmall' { StorageErrorKind.invalid_object_key }
'FileGroupTooLarge' { StorageErrorKind.quota_exceeded }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidArgument' { StorageErrorKind.invalid_object_key }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidDigest' { StorageErrorKind.checksum_mismatch }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'TooManyBuckets' { StorageErrorKind.quota_exceeded }
else { error_kind_from_http_status(http_status) }
}
}
fn map_tencent_cos_error_code(error_code string, http_status int) StorageErrorKind {
return match error_code {
'AccessDenied' { StorageErrorKind.access_denied }
'BucketAlreadyExists' { StorageErrorKind.bucket_already_exists }
'BucketAlreadyOwnedByYou' { StorageErrorKind.bucket_already_exists }
'BucketNotEmpty' { StorageErrorKind.bucket_not_empty }
'EntityTooLarge' { StorageErrorKind.quota_exceeded }
'EntityTooSmall' { StorageErrorKind.invalid_object_key }
'InvalidAccessKeyId' { StorageErrorKind.invalid_credentials }
'InvalidArgument' { StorageErrorKind.invalid_object_key }
'InvalidBucketName' { StorageErrorKind.invalid_object_key }
'InvalidDigest' { StorageErrorKind.checksum_mismatch }
'InvalidObjectName' { StorageErrorKind.invalid_object_key }
'InvalidRange' { StorageErrorKind.invalid_range }
'NoSuchBucket' { StorageErrorKind.bucket_not_found }
'NoSuchKey' { StorageErrorKind.object_not_found }
'NoSuchUpload' { StorageErrorKind.object_not_found }
'RequestTimeout' { StorageErrorKind.network_timeout }
'RequestTimeTooSkewed' { StorageErrorKind.invalid_credentials }
'ServiceUnavailable' { StorageErrorKind.service_unavailable }
'SignatureDoesNotMatch' { StorageErrorKind.invalid_credentials }
'SlowDown' { StorageErrorKind.rate_limited }
'TooManyBuckets' { StorageErrorKind.quota_exceeded }
else { error_kind_from_http_status(http_status) }
}
}
// ============================================================================
// XML 错误解析
// ============================================================================
fn extract_error_code_from_xml(body string) string {
if code_start := body.index('<Code>') {
if code_end := body.index('</Code>') {
return body[code_start + 6..code_end]
}
}
return ''
}
fn extract_error_message_from_xml(body string) string {
if message_start := body.index('<Message>') {
if message_end := body.index('</Message>') {
return body[message_start + 9..message_end]
}
}
return ''
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Error Classification 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 生成随机字符串
fn generate_random_string(min_len int, max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// 生成随机提供者名称
fn generate_random_provider() string {
providers := ['local', 's3', 'aliyun_oss', 'tencent_cos']
idx := rand.int_in_range(0, providers.len) or { 0 }
return providers[idx]
}
// 生成随机操作名称
fn generate_random_operation() string {
operations := ['upload', 'download', 'delete', 'exists', 'head', 'list', 'copy', 'presign_url']
idx := rand.int_in_range(0, operations.len) or { 0 }
return operations[idx]
}
// ============================================================================
// Property 8.1: Retryable Error Classification
// For any error of type network_timeout, service_unavailable, rate_limited,
// connection_reset, or temporary_failure, is_retryable() should return true
// ============================================================================
fn test_property_8_1_retryable_errors() bool {
retryable_kinds := [
StorageErrorKind.network_timeout,
StorageErrorKind.service_unavailable,
StorageErrorKind.rate_limited,
StorageErrorKind.connection_reset,
StorageErrorKind.temporary_failure,
]
for _ in 0 .. test_iterations {
// Pick a random retryable error kind
idx := rand.int_in_range(0, retryable_kinds.len) or { 0 }
kind := retryable_kinds[idx]
// Create error with random provider and operation
provider := generate_random_provider()
operation := generate_random_operation()
message := generate_random_string(10, 50)
err := new_storage_error(kind, message, provider, operation)
// Verify is_retryable returns true
if !err.is_retryable() {
println(' Failed: Error kind ${kind} should be retryable but is_retryable() returned false')
return false
}
// Verify category is 'retryable'
if err.category() != 'retryable' {
println(' Failed: Error kind ${kind} should have category "retryable" but got "${err.category()}"')
return false
}
}
return true
}
// ============================================================================
// Property 8.2: Non-Retryable Error Classification
// For any error of type invalid_credentials, access_denied, bucket_not_found,
// object_not_found, invalid_object_key, quota_exceeded, invalid_config,
// is_retryable() should return false
// ============================================================================
fn test_property_8_2_non_retryable_errors() bool {
non_retryable_kinds := [
StorageErrorKind.invalid_credentials,
StorageErrorKind.access_denied,
StorageErrorKind.bucket_not_found,
StorageErrorKind.object_not_found,
StorageErrorKind.invalid_object_key,
StorageErrorKind.quota_exceeded,
StorageErrorKind.invalid_config,
StorageErrorKind.bucket_not_empty,
StorageErrorKind.bucket_already_exists,
StorageErrorKind.object_already_exists,
StorageErrorKind.invalid_range,
StorageErrorKind.checksum_mismatch,
StorageErrorKind.unknown,
]
for _ in 0 .. test_iterations {
// Pick a random non-retryable error kind
idx := rand.int_in_range(0, non_retryable_kinds.len) or { 0 }
kind := non_retryable_kinds[idx]
// Create error with random provider and operation
provider := generate_random_provider()
operation := generate_random_operation()
message := generate_random_string(10, 50)
err := new_storage_error(kind, message, provider, operation)
// Verify is_retryable returns false
if err.is_retryable() {
println(' Failed: Error kind ${kind} should NOT be retryable but is_retryable() returned true')
return false
}
// Verify category is 'non_retryable'
if err.category() != 'non_retryable' {
println(' Failed: Error kind ${kind} should have category "non_retryable" but got "${err.category()}"')
return false
}
}
return true
}
// ============================================================================
// Property 8.3: HTTP Status Code Mapping
// For any HTTP status code, error_kind_from_http_status should return
// the correct error kind
// ============================================================================
fn test_property_8_3_http_status_mapping() bool {
// Define expected mappings
status_mappings := {
400: StorageErrorKind.invalid_object_key
401: StorageErrorKind.invalid_credentials
403: StorageErrorKind.access_denied
404: StorageErrorKind.object_not_found
409: StorageErrorKind.bucket_already_exists
413: StorageErrorKind.quota_exceeded
416: StorageErrorKind.invalid_range
429: StorageErrorKind.rate_limited
500: StorageErrorKind.service_unavailable
502: StorageErrorKind.service_unavailable
503: StorageErrorKind.service_unavailable
504: StorageErrorKind.network_timeout
}
for status, expected_kind in status_mappings {
actual_kind := error_kind_from_http_status(status)
if actual_kind != expected_kind {
println(' Failed: HTTP status ${status} should map to ${expected_kind} but got ${actual_kind}')
return false
}
}
// Test unknown status codes
unknown_statuses := [200, 201, 204, 301, 302, 418, 451]
for status in unknown_statuses {
kind := error_kind_from_http_status(status)
if kind != StorageErrorKind.unknown {
println(' Failed: HTTP status ${status} should map to unknown but got ${kind}')
return false
}
}
return true
}
// ============================================================================
// Property 8.4: S3 Error Code Mapping
// For any S3 error code, map_s3_error_code should return the correct error kind
// ============================================================================
fn test_property_8_4_s3_error_code_mapping() bool {
// Define expected mappings for S3 error codes
s3_mappings := {
'AccessDenied': StorageErrorKind.access_denied
'InvalidAccessKeyId': StorageErrorKind.invalid_credentials
'NoSuchBucket': StorageErrorKind.bucket_not_found
'NoSuchKey': StorageErrorKind.object_not_found
'BucketAlreadyExists': StorageErrorKind.bucket_already_exists
'BucketNotEmpty': StorageErrorKind.bucket_not_empty
'RequestTimeout': StorageErrorKind.network_timeout
'ServiceUnavailable': StorageErrorKind.service_unavailable
'SlowDown': StorageErrorKind.rate_limited
'InvalidRange': StorageErrorKind.invalid_range
'SignatureDoesNotMatch': StorageErrorKind.invalid_credentials
}
for error_code, expected_kind in s3_mappings {
actual_kind := map_s3_error_code(error_code, 0)
if actual_kind != expected_kind {
println(' Failed: S3 error code "${error_code}" should map to ${expected_kind} but got ${actual_kind}')
return false
}
}
return true
}
// ============================================================================
// Property 8.5: Aliyun OSS Error Code Mapping
// For any Aliyun OSS error code, map_aliyun_oss_error_code should return
// the correct error kind
// ============================================================================
fn test_property_8_5_aliyun_oss_error_code_mapping() bool {
// Define expected mappings for Aliyun OSS error codes
oss_mappings := {
'AccessDenied': StorageErrorKind.access_denied
'InvalidAccessKeyId': StorageErrorKind.invalid_credentials
'NoSuchBucket': StorageErrorKind.bucket_not_found
'NoSuchKey': StorageErrorKind.object_not_found
'BucketAlreadyExists': StorageErrorKind.bucket_already_exists
'BucketNotEmpty': StorageErrorKind.bucket_not_empty
'RequestTimeout': StorageErrorKind.network_timeout
'ServiceUnavailable': StorageErrorKind.service_unavailable
'InvalidRange': StorageErrorKind.invalid_range
'InvalidDigest': StorageErrorKind.checksum_mismatch
'SignatureDoesNotMatch': StorageErrorKind.invalid_credentials
'EntityTooLarge': StorageErrorKind.quota_exceeded
}
for error_code, expected_kind in oss_mappings {
actual_kind := map_aliyun_oss_error_code(error_code, 0)
if actual_kind != expected_kind {
println(' Failed: Aliyun OSS error code "${error_code}" should map to ${expected_kind} but got ${actual_kind}')
return false
}
}
return true
}
// ============================================================================
// Property 8.6: Tencent COS Error Code Mapping
// For any Tencent COS error code, map_tencent_cos_error_code should return
// the correct error kind
// ============================================================================
fn test_property_8_6_tencent_cos_error_code_mapping() bool {
// Define expected mappings for Tencent COS error codes
cos_mappings := {
'AccessDenied': StorageErrorKind.access_denied
'InvalidAccessKeyId': StorageErrorKind.invalid_credentials
'NoSuchBucket': StorageErrorKind.bucket_not_found
'NoSuchKey': StorageErrorKind.object_not_found
'BucketAlreadyExists': StorageErrorKind.bucket_already_exists
'BucketNotEmpty': StorageErrorKind.bucket_not_empty
'RequestTimeout': StorageErrorKind.network_timeout
'ServiceUnavailable': StorageErrorKind.service_unavailable
'SlowDown': StorageErrorKind.rate_limited
'InvalidRange': StorageErrorKind.invalid_range
'InvalidDigest': StorageErrorKind.checksum_mismatch
'SignatureDoesNotMatch': StorageErrorKind.invalid_credentials
'EntityTooLarge': StorageErrorKind.quota_exceeded
}
for error_code, expected_kind in cos_mappings {
actual_kind := map_tencent_cos_error_code(error_code, 0)
if actual_kind != expected_kind {
println(' Failed: Tencent COS error code "${error_code}" should map to ${expected_kind} but got ${actual_kind}')
return false
}
}
return true
}
// ============================================================================
// Property 8.7: Error Message Contains Required Information
// For any storage error, the error message should contain provider, operation,
// and error kind information
// ============================================================================
fn test_property_8_7_error_message_format() bool {
all_kinds := [
StorageErrorKind.network_timeout,
StorageErrorKind.service_unavailable,
StorageErrorKind.rate_limited,
StorageErrorKind.connection_reset,
StorageErrorKind.temporary_failure,
StorageErrorKind.invalid_credentials,
StorageErrorKind.access_denied,
StorageErrorKind.bucket_not_found,
StorageErrorKind.object_not_found,
StorageErrorKind.invalid_object_key,
StorageErrorKind.quota_exceeded,
StorageErrorKind.invalid_config,
]
for _ in 0 .. test_iterations {
// Pick a random error kind
idx := rand.int_in_range(0, all_kinds.len) or { 0 }
kind := all_kinds[idx]
provider := generate_random_provider()
operation := generate_random_operation()
message := generate_random_string(10, 50)
err := new_storage_error(kind, message, provider, operation)
error_msg := err.msg()
// Verify error message contains provider
if !error_msg.contains(provider) {
println(' Failed: Error message should contain provider "${provider}" but got: ${error_msg}')
return false
}
// Verify error message contains operation
if !error_msg.contains(operation) {
println(' Failed: Error message should contain operation "${operation}" but got: ${error_msg}')
return false
}
// Verify error message contains kind
if !error_msg.contains('kind:') {
println(' Failed: Error message should contain "kind:" but got: ${error_msg}')
return false
}
}
return true
}
// ============================================================================
// Property 8.8: Suggested Retry Delay for Retryable Errors
// For any retryable error, suggested_retry_delay should return a positive value
// ============================================================================
fn test_property_8_8_suggested_retry_delay() bool {
retryable_kinds := [
StorageErrorKind.network_timeout,
StorageErrorKind.service_unavailable,
StorageErrorKind.rate_limited,
StorageErrorKind.connection_reset,
StorageErrorKind.temporary_failure,
]
for _ in 0 .. test_iterations {
// Pick a random retryable error kind
idx := rand.int_in_range(0, retryable_kinds.len) or { 0 }
kind := retryable_kinds[idx]
provider := generate_random_provider()
operation := generate_random_operation()
message := generate_random_string(10, 50)
err := new_storage_error(kind, message, provider, operation)
delay := err.suggested_retry_delay()
// Verify delay is positive for retryable errors
if delay <= 0 {
println(' Failed: Retryable error kind ${kind} should have positive suggested_retry_delay but got ${delay}')
return false
}
}
// Verify non-retryable errors have zero delay
non_retryable_kinds := [
StorageErrorKind.invalid_credentials,
StorageErrorKind.access_denied,
StorageErrorKind.object_not_found,
]
for kind in non_retryable_kinds {
err := new_storage_error(kind, 'test', 'test', 'test')
delay := err.suggested_retry_delay()
if delay != 0 {
println(' Failed: Non-retryable error kind ${kind} should have zero suggested_retry_delay but got ${delay}')
return false
}
}
return true
}
// ============================================================================
// Property 8.9: XML Error Code Extraction
// For any XML error response, extract_error_code_from_xml should correctly
// extract the error code
// ============================================================================
fn test_property_8_9_xml_error_code_extraction() bool {
for _ in 0 .. test_iterations {
// Generate random error code
error_code := generate_random_string(5, 20)
error_message := generate_random_string(10, 50)
// Create XML response
xml_response := '<?xml version="1.0" encoding="UTF-8"?><Error><Code>${error_code}</Code><Message>${error_message}</Message></Error>'
// Extract error code
extracted_code := extract_error_code_from_xml(xml_response)
if extracted_code != error_code {
println(' Failed: Expected error code "${error_code}" but got "${extracted_code}"')
return false
}
// Extract error message
extracted_message := extract_error_message_from_xml(xml_response)
if extracted_message != error_message {
println(' Failed: Expected error message "${error_message}" but got "${extracted_message}"')
return false
}
}
// Test with empty/invalid XML
empty_code := extract_error_code_from_xml('')
if empty_code != '' {
println(' Failed: Empty XML should return empty error code but got "${empty_code}"')
return false
}
invalid_code := extract_error_code_from_xml('<Invalid>XML</Invalid>')
if invalid_code != '' {
println(' Failed: Invalid XML should return empty error code but got "${invalid_code}"')
return false
}
return true
}
// ============================================================================
// Property 8.10: Retry History Tracking
// For any storage error, retry history should be correctly tracked
// ============================================================================
fn test_property_8_10_retry_history_tracking() bool {
for _ in 0 .. test_iterations {
provider := generate_random_provider()
operation := generate_random_operation()
message := generate_random_string(10, 50)
mut err := new_storage_error(
StorageErrorKind.network_timeout,
message,
provider,
operation
)
// Initially, retry history should be empty
if err.retry_history.len != 0 {
println(' Failed: New error should have empty retry history')
return false
}
// Add some retry attempts
num_retries := rand.int_in_range(1, 5) or { 1 }
for i in 0 .. num_retries {
delay := rand.int_in_range(100, 5000) or { 1000 }
err.add_retry_attempt(i + 1, 'Retry attempt ${i + 1}', delay)
}
// Verify retry history length
if err.retry_history.len != num_retries {
println(' Failed: Expected ${num_retries} retry attempts but got ${err.retry_history.len}')
return false
}
// Verify retry summary is not empty
summary := err.retry_summary()
if summary == '' || summary == 'No retry attempts' {
println(' Failed: Retry summary should not be empty after adding attempts')
return false
}
// Verify each attempt is recorded correctly
for i, attempt in err.retry_history {
if attempt.attempt_number != i + 1 {
println(' Failed: Attempt number mismatch at index ${i}')
return false
}
}
}
return true
}
fn main() {
println('🚀 开始 Error Classification 属性测试...')
println('Feature: v-hono-upload-integration, Property 8: Error Classification')
println('Validates: Requirements 9.4')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(54321)])
mut stats := PropertyTestStats{}
// Run property tests
stats.run_property_test('Property 8.1: Retryable Error Classification', test_property_8_1_retryable_errors)
stats.run_property_test('Property 8.2: Non-Retryable Error Classification', test_property_8_2_non_retryable_errors)
stats.run_property_test('Property 8.3: HTTP Status Code Mapping', test_property_8_3_http_status_mapping)
stats.run_property_test('Property 8.4: S3 Error Code Mapping', test_property_8_4_s3_error_code_mapping)
stats.run_property_test('Property 8.5: Aliyun OSS Error Code Mapping', test_property_8_5_aliyun_oss_error_code_mapping)
stats.run_property_test('Property 8.6: Tencent COS Error Code Mapping', test_property_8_6_tencent_cos_error_code_mapping)
stats.run_property_test('Property 8.7: Error Message Contains Required Information', test_property_8_7_error_message_format)
stats.run_property_test('Property 8.8: Suggested Retry Delay for Retryable Errors', test_property_8_8_suggested_retry_delay)
stats.run_property_test('Property 8.9: XML Error Code Extraction', test_property_8_9_xml_error_code_extraction)
stats.run_property_test('Property 8.10: Retry History Tracking', test_property_8_10_retry_history_tracking)
// Print summary
stats.print_summary()
// Exit with error code if any tests failed
if stats.failed_tests > 0 {
exit(1)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import net.http
fn main() {
+402
View File
@@ -0,0 +1,402 @@
// FastCache 优化测试
// 验证高性能缓存的正确性和性能(通过 FastRouter API)
module main
import meiseayoung.hono
import net.http
import time
struct TestResult {
mut:
name string
passed bool
detail string
}
fn main() {
println('╔═══════════════════════════════════════════════════════════════╗')
println('║ FastCache 优化测试 ║')
println('╚═══════════════════════════════════════════════════════════════╝')
println('')
mut results := []TestResult{}
// 1. 路由匹配正确性测试
results << test_route_matching_correctness()
// 2. 参数提取正确性测试
results << test_param_extraction()
// 3. 缓存命中测试
results << test_cache_hit()
// 4. 多路由并发测试
results << test_multiple_routes()
// 5. 性能基准测试
results << test_performance_benchmark()
// 6. 缓存一致性测试
results << test_cache_consistency()
// 7. 缓存清理测试
results << test_cache_clear()
// 8. 缓存健康检查测试
results << test_cache_health()
// 打印结果
println('')
println('═══════════════════════════════════════════════════════════════')
println('📊 测试结果汇总')
println('═══════════════════════════════════════════════════════════════')
mut passed := 0
mut failed := 0
for result in results {
status := if result.passed { '✅' } else { '❌' }
println('${status} ${result.name}')
if result.detail.len > 0 {
println(' ${result.detail}')
}
if result.passed {
passed++
} else {
failed++
}
}
println('')
println('总计: ${passed}/${results.len} 通过')
if failed == 0 {
println('🎉 所有测试通过!FastCache 优化验证成功!')
} else {
println('⚠️ 有 ${failed} 个测试失败')
}
}
// 1. 路由匹配正确性测试
fn test_route_matching_correctness() TestResult {
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
return c.text('user')
})
app.get('/posts/:post_id/comments/:comment_id', fn (mut c hono.Context) http.Response {
return c.text('comment')
})
mut router := app.fast_router
// 测试单参数路由
if match1 := router.match_route('GET', '/users/456') {
if match1.params['id'] or { '' } != '456' {
return TestResult{name: '路由匹配正确性', passed: false, detail: '单参数提取错误'}
}
} else {
return TestResult{name: '路由匹配正确性', passed: false, detail: '单参数路由匹配失败'}
}
// 测试多参数路由
if match2 := router.match_route('GET', '/posts/100/comments/200') {
post_id := match2.params['post_id'] or { '' }
comment_id := match2.params['comment_id'] or { '' }
if post_id != '100' || comment_id != '200' {
return TestResult{name: '路由匹配正确性', passed: false, detail: '多参数提取错误: post_id=${post_id}, comment_id=${comment_id}'}
}
} else {
return TestResult{name: '路由匹配正确性', passed: false, detail: '多参数路由匹配失败'}
}
return TestResult{name: '路由匹配正确性', passed: true, detail: '单参数和多参数路由均正确'}
}
// 2. 参数提取正确性测试
fn test_param_extraction() TestResult {
mut app := hono.Hono.new()
app.get('/api/:version/users/:user_id/posts/:post_id', fn (mut c hono.Context) http.Response {
return c.text('ok')
})
mut router := app.fast_router
test_cases := [
['/api/v1/users/100/posts/200', 'v1', '100', '200'],
['/api/v2/users/abc/posts/xyz', 'v2', 'abc', 'xyz'],
['/api/beta/users/user-123/posts/post-456', 'beta', 'user-123', 'post-456'],
]
for tc in test_cases {
path := tc[0]
expected_version := tc[1]
expected_user := tc[2]
expected_post := tc[3]
if match_result := router.match_route('GET', path) {
version := match_result.params['version'] or { '' }
user_id := match_result.params['user_id'] or { '' }
post_id := match_result.params['post_id'] or { '' }
if version != expected_version || user_id != expected_user || post_id != expected_post {
return TestResult{
name: '参数提取正确性'
passed: false
detail: '路径 ${path} 参数提取错误'
}
}
} else {
return TestResult{name: '参数提取正确性', passed: false, detail: '路径 ${path} 匹配失败'}
}
}
return TestResult{name: '参数提取正确性', passed: true, detail: '${test_cases.len} 个测试用例全部通过'}
}
// 3. 缓存命中测试
fn test_cache_hit() TestResult {
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
return c.text('user')
})
mut router := app.fast_router
// 第一次匹配(缓存未命中,需要正则匹配)
_ := router.match_route('GET', '/users/123')
// 获取缓存统计
cache_size1, _ := router.get_cache_stats()
// 第二次匹配(应该命中缓存)
if match2 := router.match_route('GET', '/users/123') {
if match2.params['id'] or { '' } == '123' {
cache_size2, _ := router.get_cache_stats()
return TestResult{
name: '缓存命中测试'
passed: true
detail: '缓存大小: ${cache_size1} -> ${cache_size2},参数正确'
}
}
}
return TestResult{name: '缓存命中测试', passed: false, detail: '缓存命中后参数错误'}
}
// 4. 多路由并发测试
fn test_multiple_routes() TestResult {
mut app := hono.Hono.new()
// 注册多个路由
app.get('/users/:id', fn (mut c hono.Context) http.Response { return c.text('user') })
app.get('/posts/:id', fn (mut c hono.Context) http.Response { return c.text('post') })
app.get('/comments/:id', fn (mut c hono.Context) http.Response { return c.text('comment') })
app.get('/tags/:name', fn (mut c hono.Context) http.Response { return c.text('tag') })
mut router := app.fast_router
// 交替匹配不同路由
test_paths := [
['/users/1', 'id', '1'],
['/posts/2', 'id', '2'],
['/comments/3', 'id', '3'],
['/tags/golang', 'name', 'golang'],
['/users/100', 'id', '100'],
['/posts/200', 'id', '200'],
]
for tc in test_paths {
path := tc[0]
param_name := tc[1]
expected_value := tc[2]
if match_result := router.match_route('GET', path) {
actual_value := match_result.params[param_name] or { '' }
if actual_value != expected_value {
return TestResult{
name: '多路由并发测试'
passed: false
detail: '路径 ${path} 参数错误: 期望 ${expected_value}, 实际 ${actual_value}'
}
}
} else {
return TestResult{name: '多路由并发测试', passed: false, detail: '路径 ${path} 匹配失败'}
}
}
return TestResult{name: '多路由并发测试', passed: true, detail: '${test_paths.len} 个路径全部正确'}
}
// 5. 性能基准测试
fn test_performance_benchmark() TestResult {
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
return c.text('user')
})
mut router := app.fast_router
// 预热
for i in 0 .. 100 {
_ := router.match_route('GET', '/users/${i}')
}
// 测试缓存命中性能
iterations := 10000
sw := time.new_stopwatch()
for _ in 0 .. iterations {
_ := router.match_route('GET', '/users/50') // 命中缓存
}
elapsed := sw.elapsed()
avg_ns := elapsed.nanoseconds() / iterations
avg_us := f64(avg_ns) / 1000.0
// 缓存命中性能评估(考虑不同平台差异)
if avg_us < 1.0 {
return TestResult{
name: '性能基准测试'
passed: true
detail: '缓存命中平均耗时: ${avg_us:.3f}μs (< 1μs, 优秀 ✓)'
}
} else if avg_us < 10.0 {
return TestResult{
name: '性能基准测试'
passed: true
detail: '缓存命中平均耗时: ${avg_us:.3f}μs (< 10μs, 良好)'
}
} else if avg_us < 50.0 {
return TestResult{
name: '性能基准测试'
passed: true
detail: '缓存命中平均耗时: ${avg_us:.3f}μs (< 50μs, 可接受)'
}
}
return TestResult{
name: '性能基准测试'
passed: false
detail: '缓存命中平均耗时: ${avg_us:.3f}μs (> 50μs, 过慢)'
}
}
// 6. 缓存一致性测试
fn test_cache_consistency() TestResult {
mut app := hono.Hono.new()
app.get('/items/:id', fn (mut c hono.Context) http.Response {
return c.text('item')
})
mut router := app.fast_router
// 多次匹配相同路径,验证结果一致
mut results := []string{}
for _ in 0 .. 100 {
if match_result := router.match_route('GET', '/items/999') {
results << match_result.params['id'] or { 'error' }
} else {
results << 'no_match'
}
}
// 检查所有结果是否一致
first := results[0]
for i, r in results {
if r != first {
return TestResult{
name: '缓存一致性测试'
passed: false
detail: '第 ${i} 次结果不一致: ${r} != ${first}'
}
}
}
if first == '999' {
return TestResult{name: '缓存一致性测试', passed: true, detail: '100 次匹配结果完全一致'}
}
return TestResult{name: '缓存一致性测试', passed: false, detail: '参数值错误: ${first}'}
}
// 7. 缓存清理测试
fn test_cache_clear() TestResult {
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
return c.text('user')
})
mut router := app.fast_router
// 填充缓存
for i in 0 .. 10 {
_ := router.match_route('GET', '/users/${i}')
}
// 获取清理前的缓存大小
size_before, _ := router.get_cache_stats()
// 清理缓存
router.clear_cache()
// 获取清理后的缓存大小
size_after, _ := router.get_cache_stats()
if size_after == 0 {
return TestResult{
name: '缓存清理测试'
passed: true
detail: '清理前: ${size_before}, 清理后: ${size_after}'
}
}
return TestResult{
name: '缓存清理测试'
passed: false
detail: '清理后缓存大小应为0,实际为: ${size_after}'
}
}
// 8. 缓存健康检查测试
fn test_cache_health() TestResult {
mut app := hono.Hono.new()
app.get('/users/:id', fn (mut c hono.Context) http.Response {
return c.text('user')
})
mut router := app.fast_router
// 填充一些缓存
for i in 0 .. 50 {
_ := router.match_route('GET', '/users/${i}')
}
// 检查健康状态
is_healthy := router.is_healthy()
// 获取详细统计
stats := router.get_detailed_stats()
if is_healthy {
return TestResult{
name: '缓存健康检查测试'
passed: true
detail: '缓存健康,统计项数: ${stats.len}'
}
}
return TestResult{
name: '缓存健康检查测试'
passed: false
detail: '缓存不健康'
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+245
View File
@@ -0,0 +1,245 @@
// 高并发测试 (5000-10000 并发)
//
// 使用方法:
// 1. 启动服务器 (必须设置 ulimit): ulimit -n 65535 && ./bench_server_usockets
// 2. 运行测试: ulimit -n 65535 && go run v-hono/tests/test_high_concurrency.go
// 3. 单独测试某个级别: ulimit -n 65535 && go run v-hono/tests/test_high_concurrency.go 8000
//
// 重要提示:
// - 服务器和客户端都需要设置 ulimit -n 65535
// - 连续测试多个并发级别时,建议每次测试前重启服务器
// - TIME_WAIT 连接会影响后续测试,建议等待 10 秒后再测试下一个级别
//
// 系统配置要求 (macOS):
// sudo sysctl -w kern.ipc.somaxconn=8192
// ulimit -n 65535
package main
import (
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
)
type BenchResult struct {
Concurrency int
TotalReqs int64
SuccessReqs int64
RPS float64
AvgLatency float64
P99Latency float64
SuccessRate float64
}
func main() {
baseURL := "http://127.0.0.1:8080/"
duration := 10 * time.Second
// 默认测试级别: 5000, 6000, 7000, 8000, 9000, 10000
concurrencyLevels := []int{5000, 6000, 7000, 8000, 9000, 10000}
// 支持命令行参数指定单个并发级别
if len(os.Args) > 1 {
level, err := strconv.Atoi(os.Args[1])
if err == nil {
concurrencyLevels = []int{level}
}
}
fmt.Println("╔═══════════════════════════════════════════════════════════════════════════╗")
fmt.Println("║ v-hono 高并发测试 (5000-10000 并发) ║")
fmt.Println("╚═══════════════════════════════════════════════════════════════════════════╝")
fmt.Println()
// 检查服务器是否运行
if !checkServer(baseURL) {
fmt.Println("❌ 服务器未运行")
fmt.Println(" 请先启动: ./bench_server_usockets")
fmt.Println()
fmt.Println(" 系统配置要求:")
fmt.Println(" - macOS: sudo sysctl -w kern.ipc.somaxconn=8192")
fmt.Println(" - ulimit -n 65535")
return
}
fmt.Println("✅ 服务器已就绪")
fmt.Println()
results := make([]BenchResult, 0, len(concurrencyLevels))
for _, conns := range concurrencyLevels {
result := runBenchmark(baseURL, conns, duration)
results = append(results, result)
// 测试间隔,让系统恢复 (TIME_WAIT 连接需要时间释放)
// macOS TIME_WAIT 默认 15-30 秒,建议等待 60 秒
if len(concurrencyLevels) > 1 {
fmt.Println(" ⏳ 等待 60 秒让系统恢复 (TIME_WAIT 连接释放)...")
time.Sleep(60 * time.Second)
}
}
// 输出汇总报告
printSummary(results)
}
func checkServer(baseURL string) bool {
client := &http.Client{Timeout: 2 * time.Second}
for i := 0; i < 5; i++ {
resp, err := client.Get(baseURL)
if err == nil && resp.StatusCode < 400 {
resp.Body.Close()
return true
}
time.Sleep(200 * time.Millisecond)
}
return false
}
func runBenchmark(baseURL string, conns int, duration time.Duration) BenchResult {
fmt.Printf("🔥 测试 %d 并发连接 (持续 %v)\n", conns, duration)
var total, success int64
var totalLatencyNs int64
latencies := make([]int64, 0, 1000000)
var mu sync.Mutex
client := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: conns * 2,
MaxIdleConnsPerHost: conns * 2,
MaxConnsPerHost: 0,
IdleConnTimeout: 120 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: false,
},
}
var wg sync.WaitGroup
stop := make(chan struct{})
start := time.Now()
// 同时启动所有连接
for i := 0; i < conns; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
t0 := time.Now()
resp, err := client.Get(baseURL)
lat := time.Since(t0).Nanoseconds()
atomic.AddInt64(&total, 1)
atomic.AddInt64(&totalLatencyNs, lat)
mu.Lock()
latencies = append(latencies, lat)
mu.Unlock()
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode < 400 {
atomic.AddInt64(&success, 1)
}
}
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
elapsed := time.Since(start)
// 计算 P99
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
p99 := int64(0)
if len(latencies) > 0 {
idx := len(latencies) * 99 / 100
p99 = latencies[idx]
}
avgLat := float64(totalLatencyNs) / float64(total) / 1e6
rps := float64(total) / elapsed.Seconds()
successRate := float64(success) / float64(total) * 100
// 判断测试结果
status := "✅"
if successRate < 99.0 {
status = "⚠️"
}
if successRate < 90.0 {
status = "❌"
}
fmt.Printf(" %s RPS: %8.0f Avg: %6.2fms P99: %6.2fms Success: %.1f%%\n",
status, rps, avgLat, float64(p99)/1e6, successRate)
fmt.Println()
return BenchResult{
Concurrency: conns,
TotalReqs: total,
SuccessReqs: success,
RPS: rps,
AvgLatency: avgLat,
P99Latency: float64(p99) / 1e6,
SuccessRate: successRate,
}
}
func printSummary(results []BenchResult) {
if len(results) <= 1 {
return
}
fmt.Println("═══════════════════════════════════════════════════════════════════════════")
fmt.Println(" 📊 测试结果汇总")
fmt.Println("═══════════════════════════════════════════════════════════════════════════")
fmt.Println()
fmt.Println("┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐")
fmt.Println("│ 并发数 │ RPS │ 平均延迟 │ P99延迟 │ 成功率 │")
fmt.Println("├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤")
allPassed := true
for _, r := range results {
status := "✅"
if r.SuccessRate < 99.0 {
status = "⚠️"
allPassed = false
}
if r.SuccessRate < 90.0 {
status = "❌"
allPassed = false
}
fmt.Printf("│ %s %6d │ %10.0f │ %8.2fms │ %8.2fms │ %8.1f%% │\n",
status, r.Concurrency, r.RPS, r.AvgLatency, r.P99Latency, r.SuccessRate)
}
fmt.Println("└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘")
fmt.Println()
if allPassed {
fmt.Println("🎉 所有高并发测试通过!v-hono uSockets 后端表现优秀!")
} else {
fmt.Println("⚠️ 部分测试未达到 99% 成功率,请检查系统配置")
fmt.Println(" - macOS: sudo sysctl -w kern.ipc.somaxconn=8192")
fmt.Println(" - ulimit -n 65535")
}
fmt.Println()
}
+282
View File
@@ -0,0 +1,282 @@
module main
import time
// 测试 HTTP 解析优化
// 原始版本 - 使用 split 和 index
fn parse_path_and_query_original(full_path string) (string, map[string]string) {
mut query_map := map[string]string{}
if idx := full_path.index('?') {
path := full_path[..idx]
query_str := full_path[idx + 1..]
for part in query_str.split('&') {
if eq_idx := part.index('=') {
query_map[part[..eq_idx]] = part[eq_idx + 1..]
}
}
return path, query_map
}
return full_path, query_map
}
// 优化版本 - 零分配单次遍历
fn parse_path_and_query_optimized(full_path string) (string, map[string]string) {
mut query_map := map[string]string{}
len := full_path.len
if len == 0 {
return full_path, query_map
}
// 1. 查找 '?' 位置(单次遍历)
mut query_start := -1
for i in 0 .. len {
if full_path[i] == `?` {
query_start = i
break
}
}
// 没有查询参数,直接返回
if query_start == -1 {
return full_path, query_map
}
path := full_path[..query_start]
// 2. 解析查询参数(单次遍历,避免 split)
mut key_start := query_start + 1
mut key_end := -1
mut value_start := -1
for i := query_start + 1; i <= len; i++ {
ch := if i < len { full_path[i] } else { `&` } // 末尾视为分隔符
if ch == `=` && key_end == -1 {
key_end = i
value_start = i + 1
} else if ch == `&` {
// 完成一个键值对
if key_end > key_start && value_start > 0 {
key := full_path[key_start..key_end]
value := if value_start < i { full_path[value_start..i] } else { '' }
query_map[key] = value
} else if key_end == -1 && i > key_start {
// 只有 key 没有 value(如 ?foo&bar=1)
key := full_path[key_start..i]
query_map[key] = ''
}
// 重置状态
key_start = i + 1
key_end = -1
value_start = -1
}
}
return path, query_map
}
// Keep-Alive 检查 - 原始版本
fn check_keepalive_original(name string, value string) bool {
return name.to_lower() == 'connection' && value.to_lower().contains('keep-alive')
}
// 大小写不敏感比较
@[inline]
fn eq_ignore_case(a string, b string) bool {
if a.len != b.len {
return false
}
for i in 0 .. a.len {
ca := a[i]
cb := b[i]
la := if ca >= `A` && ca <= `Z` { ca + 32 } else { ca }
lb := if cb >= `A` && cb <= `Z` { cb + 32 } else { cb }
if la != lb {
return false
}
}
return true
}
// 大小写不敏感 contains
@[inline]
fn contains_ignore_case(haystack string, needle string) bool {
if needle.len > haystack.len {
return false
}
max_start := haystack.len - needle.len
for i := 0; i <= max_start; i++ {
mut found := true
for j in 0 .. needle.len {
ch := haystack[i + j]
cn := needle[j]
lh := if ch >= `A` && ch <= `Z` { ch + 32 } else { ch }
ln := if cn >= `A` && cn <= `Z` { cn + 32 } else { cn }
if lh != ln {
found = false
break
}
}
if found {
return true
}
}
return false
}
// Keep-Alive 检查 - 优化版本
fn check_keepalive_optimized(name string, value string) bool {
return name.len == 10 && eq_ignore_case(name, 'connection') && contains_ignore_case(value, 'keep-alive')
}
// 测试正确性
fn test_correctness() {
println('=== 测试正确性 ===')
test_cases := [
'/api/users',
'/api/users?id=123',
'/api/users?id=123&name=test',
'/api/users?id=123&name=test&active=true',
'/search?q=hello+world&page=1&limit=10',
'/path?empty=&key=value',
'/path?novalue&key=value',
'/',
'/?single=param',
]
mut all_passed := true
for tc in test_cases {
path_orig, query_orig := parse_path_and_query_original(tc)
path_opt, query_opt := parse_path_and_query_optimized(tc)
if path_orig != path_opt {
println('❌ 路径不匹配: ${tc}')
println(' 原始: ${path_orig}')
println(' 优化: ${path_opt}')
all_passed = false
} else if query_orig.len != query_opt.len {
println('❌ 查询参数数量不匹配: ${tc}')
println(' 原始: ${query_orig}')
println(' 优化: ${query_opt}')
all_passed = false
} else {
mut params_match := true
for k, v in query_orig {
if k !in query_opt || query_opt[k] != v {
params_match = false
break
}
}
if !params_match {
println('❌ 查询参数值不匹配: ${tc}')
println(' 原始: ${query_orig}')
println(' 优化: ${query_opt}')
all_passed = false
} else {
println('✓ ${tc}')
}
}
}
if all_passed {
println('\n✅ 所有正确性测试通过!')
} else {
println('\n❌ 部分测试失败')
}
}
// 性能基准测试
fn benchmark_performance() {
println('\n=== 性能基准测试 ===')
// 测试用例
test_paths := [
'/api/users', // 无查询参数
'/api/users?id=123', // 单个参数
'/api/users?id=123&name=test&active=true', // 多个参数
'/search?q=hello+world&page=1&limit=10&sort=desc&filter=active', // 复杂查询
]
iterations := 100000
for test_path in test_paths {
// 原始版本
sw_orig := time.new_stopwatch()
for _ in 0 .. iterations {
_, _ := parse_path_and_query_original(test_path)
}
time_orig := sw_orig.elapsed().microseconds()
// 优化版本
sw_opt := time.new_stopwatch()
for _ in 0 .. iterations {
_, _ := parse_path_and_query_optimized(test_path)
}
time_opt := sw_opt.elapsed().microseconds()
speedup := f64(time_orig) / f64(time_opt)
println('\n路径: ${test_path}')
println(' 原始版本: ${time_orig} μs (${iterations} 次)')
println(' 优化版本: ${time_opt} μs (${iterations} 次)')
println(' 提升: ${speedup:.2f}x')
}
}
// 测试 Keep-Alive 检查优化
fn test_keepalive_optimization() {
println('\n=== Keep-Alive 检查优化测试 ===')
test_cases := [
['Connection', 'keep-alive'],
['connection', 'Keep-Alive'],
['CONNECTION', 'KEEP-ALIVE'],
['Content-Type', 'application/json'],
['Connection', 'close'],
]
iterations := 100000
// 原始版本
sw_orig := time.new_stopwatch()
for _ in 0 .. iterations {
for tc in test_cases {
_ := check_keepalive_original(tc[0], tc[1])
}
}
time_orig := sw_orig.elapsed().microseconds()
// 优化版本
sw_opt := time.new_stopwatch()
for _ in 0 .. iterations {
for tc in test_cases {
_ := check_keepalive_optimized(tc[0], tc[1])
}
}
time_opt := sw_opt.elapsed().microseconds()
speedup := f64(time_orig) / f64(time_opt)
println('原始版本: ${time_orig} μs (${iterations * test_cases.len} 次检查)')
println('优化版本: ${time_opt} μs (${iterations * test_cases.len} 次检查)')
println('提升: ${speedup:.2f}x')
}
fn main() {
println('HTTP 解析优化测试')
println('=' .repeat(50))
test_correctness()
benchmark_performance()
test_keepalive_optimization()
println('\n' + '=' .repeat(50))
println('测试完成!')
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
import os
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import time
import net.http
+310
View File
@@ -0,0 +1,310 @@
import meiseayoung.hono
import rand
import time
// JWT 中间件属性测试
// Property-Based Testing for JWT functionality
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== JWT 中间件属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 生成随机的 subject
fn generate_random_subject() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(5, 30) or { 10 }
mut subject := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
subject += chars[idx].ascii_str()
}
return subject
}
// 生成随机的 issuer
fn generate_random_issuer() string {
prefixes := ['https://auth.', 'https://api.', 'https://']
domains := ['example.com', 'test.org', 'myapp.io', 'service.net']
prefix_idx := rand.int_in_range(0, prefixes.len) or { 0 }
domain_idx := rand.int_in_range(0, domains.len) or { 0 }
return prefixes[prefix_idx] + domains[domain_idx]
}
// 生成随机的密钥
fn generate_random_secret() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
len := rand.int_in_range(32, 64) or { 32 }
mut secret := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
secret += chars[idx].ascii_str()
}
return secret
}
// 生成随机的自定义声明
fn generate_random_claims() map[string]string {
mut claims := map[string]string{}
num_claims := rand.int_in_range(0, 5) or { 2 }
for i in 0 .. num_claims {
key := 'claim_${i}'
value := generate_random_subject()
claims[key] = value
}
return claims
}
// 生成随机的 JwtPayload
fn generate_random_payload() hono.JwtPayload {
now := time.now().unix()
return hono.JwtPayload{
sub: generate_random_subject()
iss: generate_random_issuer()
aud: 'test-audience'
exp: now + 3600 // 1 hour from now
nbf: now - 60 // 1 minute ago
iat: now
jti: generate_random_subject()
claims: generate_random_claims()
}
}
// 随机选择算法
fn random_algorithm() hono.JwtAlgorithm {
idx := rand.int_in_range(0, 3) or { 0 }
return match idx {
0 { hono.JwtAlgorithm.hs256 }
1 { hono.JwtAlgorithm.hs384 }
else { hono.JwtAlgorithm.hs512 }
}
}
// ============================================================================
// Property 6: JWT Sign-Verify Round-Trip
// Feature: builtin-middleware, Property 6: JWT Sign-Verify Round-Trip
// Validates: Requirements 3.1, 3.4, 3.11
//
// *For any* valid JwtPayload and secret, calling sign_jwt followed by verify_jwt
// with the same secret SHALL return an equivalent payload.
// ============================================================================
fn test_property_6_jwt_sign_verify_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
payload := generate_random_payload()
secret := generate_random_secret()
alg := random_algorithm()
// 签名 JWT
token := hono.sign_jwt(payload, secret, alg) or {
println(' Iteration ${i}: Failed to sign JWT: ${err}')
return false
}
// 验证 JWT
verified_payload := hono.verify_jwt(token, secret, alg) or {
println(' Iteration ${i}: Failed to verify JWT: ${err}')
return false
}
// 验证 payload 字段
if verified_payload.sub != payload.sub {
println(' Iteration ${i}: Subject mismatch - expected "${payload.sub}", got "${verified_payload.sub}"')
return false
}
if verified_payload.iss != payload.iss {
println(' Iteration ${i}: Issuer mismatch - expected "${payload.iss}", got "${verified_payload.iss}"')
return false
}
if verified_payload.aud != payload.aud {
println(' Iteration ${i}: Audience mismatch - expected "${payload.aud}", got "${verified_payload.aud}"')
return false
}
if verified_payload.exp != payload.exp {
println(' Iteration ${i}: Exp mismatch - expected ${payload.exp}, got ${verified_payload.exp}')
return false
}
if verified_payload.nbf != payload.nbf {
println(' Iteration ${i}: Nbf mismatch - expected ${payload.nbf}, got ${verified_payload.nbf}')
return false
}
if verified_payload.iat != payload.iat {
println(' Iteration ${i}: Iat mismatch - expected ${payload.iat}, got ${verified_payload.iat}')
return false
}
if verified_payload.jti != payload.jti {
println(' Iteration ${i}: JTI mismatch - expected "${payload.jti}", got "${verified_payload.jti}"')
return false
}
// 验证自定义声明
for key, value in payload.claims {
if key !in verified_payload.claims {
println(' Iteration ${i}: Missing claim "${key}"')
return false
}
if verified_payload.claims[key] != value {
println(' Iteration ${i}: Claim "${key}" mismatch - expected "${value}", got "${verified_payload.claims[key]}"')
return false
}
}
}
return true
}
// ============================================================================
// Property 7: JWT Expiration Enforcement
// Feature: builtin-middleware, Property 7: JWT Expiration Enforcement
// Validates: Requirements 3.2, 3.9
//
// *For any* JWT with an exp claim in the past, verify_jwt SHALL return an error
// when verifyOptions.exp is true.
// ============================================================================
fn test_property_7_jwt_expiration_enforcement() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
now := time.now().unix()
// 创建已过期的 payload
mut payload := generate_random_payload()
// 设置过期时间为过去(1秒到1小时前)
seconds_ago := rand.int_in_range(1, 3600) or { 60 }
payload.exp = now - seconds_ago
secret := generate_random_secret()
alg := random_algorithm()
// 签名 JWT
token := hono.sign_jwt(payload, secret, alg) or {
println(' Iteration ${i}: Failed to sign JWT: ${err}')
return false
}
// 使用带过期验证的选项验证 JWT
verify_options := hono.JwtVerifyOptions{
exp: true
nbf: false
iat: false
}
// 验证应该失败(因为 token 已过期)
if _ := hono.verify_jwt_with_options(token, secret, alg, verify_options) {
println(' Iteration ${i}: Expired JWT was accepted (should have been rejected)')
return false
}
}
return true
}
// ============================================================================
// Property 8: JWT Algorithm Consistency
// Feature: builtin-middleware, Property 8: JWT Algorithm Consistency
// Validates: Requirements 3.7
//
// *For any* JWT signed with algorithm A, verification with algorithm B
// (where A ≠ B) SHALL fail.
// ============================================================================
fn test_property_8_jwt_algorithm_consistency() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
algorithms := [hono.JwtAlgorithm.hs256, hono.JwtAlgorithm.hs384, hono.JwtAlgorithm.hs512]
for i in 0 .. test_iterations {
payload := generate_random_payload()
secret := generate_random_secret()
// 随机选择签名算法
sign_alg_idx := rand.int_in_range(0, algorithms.len) or { 0 }
sign_alg := algorithms[sign_alg_idx]
// 选择不同的验证算法
mut verify_alg_idx := rand.int_in_range(0, algorithms.len) or { 0 }
// 确保验证算法与签名算法不同
for verify_alg_idx == sign_alg_idx {
verify_alg_idx = rand.int_in_range(0, algorithms.len) or { 0 }
}
verify_alg := algorithms[verify_alg_idx]
// 签名 JWT
token := hono.sign_jwt(payload, secret, sign_alg) or {
println(' Iteration ${i}: Failed to sign JWT: ${err}')
return false
}
// 使用不同算法验证应该失败
if _ := hono.verify_jwt(token, secret, verify_alg) {
println(' Iteration ${i}: JWT verified with wrong algorithm (signed with ${sign_alg}, verified with ${verify_alg})')
return false
}
}
return true
}
fn main() {
println('🚀 开始 JWT 中间件属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// 运行属性测试
// Feature: builtin-middleware, Property 6: JWT Sign-Verify Round-Trip
// Validates: Requirements 3.1, 3.4, 3.11
stats.run_property_test('Property 6: JWT Sign-Verify Round-Trip', test_property_6_jwt_sign_verify_roundtrip)
// Feature: builtin-middleware, Property 7: JWT Expiration Enforcement
// Validates: Requirements 3.2, 3.9
stats.run_property_test('Property 7: JWT Expiration Enforcement', test_property_7_jwt_expiration_enforcement)
// Feature: builtin-middleware, Property 8: JWT Algorithm Consistency
// Validates: Requirements 3.7
stats.run_property_test('Property 8: JWT Algorithm Consistency', test_property_8_jwt_algorithm_consistency)
// 打印测试总结
stats.print_summary()
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import os
fn main() {
+710
View File
@@ -0,0 +1,710 @@
module main
import rand
import time
import os
import db.sqlite
import crypto.rand as crand
// ============================================================================
// Property 7: List Operation Completeness
// Feature: v-hono-upload-integration, Property 7: List Operation Completeness
// Validates: Requirements 8.5
//
// *For any* set of uploaded files in a bucket, the list operation should return
// all files, and pagination should not lose or duplicate any files.
// ============================================================================
const test_iterations = 50
const test_db_path = './test_list_operation.db'
// ============================================================================
// Type definitions (copied from database.v for standalone testing)
// ============================================================================
struct FileInfo {
pub:
id int
file_uuid string
file_hash string
file_name string
file_size i64
file_type string
storage_type string
bucket string
object_key string
created_at i64
updated_at i64
metadata string
}
struct FileListOptions {
pub:
bucket string
prefix string
storage_type string
limit int = 100
offset int
order_by string = 'created_at'
order_desc bool = true
}
struct FileListResult {
pub:
files []FileInfo
total_count int
has_more bool
}
// 数据库管理器
struct DatabaseManager {
mut:
db sqlite.DB
}
// 创建数据库管理器
fn new_database_manager(db_path string) !DatabaseManager {
mut db := sqlite.connect(db_path) or {
return error('Failed to connect to database: ${err}')
}
// 创建文件信息表
db.exec('CREATE TABLE IF NOT EXISTS file_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_uuid TEXT UNIQUE NOT NULL,
file_hash TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_type TEXT NOT NULL,
storage_type TEXT NOT NULL DEFAULT "local",
bucket TEXT NOT NULL DEFAULT "default",
object_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT
);') or { return error('Failed to create file_info table: ${err}') }
// 创建索引
db.exec('CREATE INDEX IF NOT EXISTS idx_file_hash ON file_info(file_hash);') or {
return error('Failed to create idx_file_hash index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_file_uuid ON file_info(file_uuid);') or {
return error('Failed to create idx_file_uuid index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_bucket_key ON file_info(bucket, object_key);') or {
return error('Failed to create idx_bucket_key index: ${err}')
}
db.exec('CREATE INDEX IF NOT EXISTS idx_storage_type ON file_info(storage_type);') or {
return error('Failed to create idx_storage_type index: ${err}')
}
return DatabaseManager{
db: db
}
}
// 生成文件UUID
fn generate_file_uuid() string {
random_bytes := crand.bytes(16) or { return '' }
mut uuid := ''
for i, b in random_bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
uuid += '-'
}
uuid += '${b:02x}'
}
return uuid
}
// SQL 字符串转义
fn escape_sql(s string) string {
return s.replace("'", "''")
}
// 插入文件信息
fn (mut dm DatabaseManager) insert_file(file FileInfo) !FileInfo {
now := time.now().unix()
file_uuid := if file.file_uuid != '' { file.file_uuid } else { generate_file_uuid() }
dm.db.exec("INSERT INTO file_info (file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata) VALUES ('${file_uuid}', '${file.file_hash}', '${escape_sql(file.file_name)}', ${file.file_size}, '${file.file_type}', '${file.storage_type}', '${file.bucket}', '${escape_sql(file.object_key)}', ${now}, ${now}, '${escape_sql(file.metadata)}')") or {
return error('Failed to insert file info: ${err}')
}
return FileInfo{
id: 0
file_uuid: file_uuid
file_hash: file.file_hash
file_name: file.file_name
file_size: file.file_size
file_type: file.file_type
storage_type: file.storage_type
bucket: file.bucket
object_key: file.object_key
created_at: now
updated_at: now
metadata: file.metadata
}
}
// 分页列表查询
fn (dm DatabaseManager) list_files(options FileListOptions) !FileListResult {
mut where_clauses := []string{}
if options.bucket != '' {
where_clauses << "bucket = '${options.bucket}'"
}
if options.prefix != '' {
where_clauses << "object_key LIKE '${escape_sql(options.prefix)}%'"
}
if options.storage_type != '' {
where_clauses << "storage_type = '${options.storage_type}'"
}
where_sql := if where_clauses.len > 0 { 'WHERE ' + where_clauses.join(' AND ') } else { '' }
order_dir := if options.order_desc { 'DESC' } else { 'ASC' }
order_sql := 'ORDER BY ${options.order_by} ${order_dir}'
// 获取总数
count_rows := dm.db.exec('SELECT COUNT(*) FROM file_info ${where_sql}') or {
return error('Failed to count files: ${err}')
}
total_count := if count_rows.len > 0 { count_rows[0].vals[0].int() } else { 0 }
// 获取分页数据
limit := if options.limit > 0 { options.limit } else { 100 }
rows := dm.db.exec('SELECT id, file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata FROM file_info ${where_sql} ${order_sql} LIMIT ${limit} OFFSET ${options.offset}') or {
return error('Failed to list files: ${err}')
}
mut files := []FileInfo{}
for row in rows {
files << FileInfo{
id: row.vals[0].int()
file_uuid: row.vals[1]
file_hash: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
file_type: row.vals[5]
storage_type: row.vals[6]
bucket: row.vals[7]
object_key: row.vals[8]
created_at: row.vals[9].i64()
updated_at: row.vals[10].i64()
metadata: row.vals[11]
}
}
return FileListResult{
files: files
total_count: total_count
has_more: options.offset + files.len < total_count
}
}
// 关闭数据库连接
fn (mut dm DatabaseManager) close() {
dm.db.close() or {}
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== List Operation Completeness 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// 生成随机字符串
fn generate_random_string(min_len int, max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// 生成随机文件名
fn generate_random_filename() string {
name := generate_random_string(5, 15)
extensions := ['.txt', '.bin', '.dat', '.json', '.xml']
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
return name + extensions[ext_idx]
}
// 生成随机 hash
fn generate_random_hash() string {
mut hash := ''
for _ in 0 .. 32 {
idx := rand.int_in_range(0, 16) or { 0 }
hash += '0123456789abcdef'[idx].ascii_str()
}
return hash
}
// 生成随机 content_type
fn generate_random_content_type() string {
types := ['text/plain', 'application/octet-stream', 'application/json', 'image/png']
idx := rand.int_in_range(0, types.len) or { 0 }
return types[idx]
}
// 生成随机存储类型
fn generate_random_storage_type() string {
types := ['local', 's3', 'aliyun_oss', 'tencent_cos']
idx := rand.int_in_range(0, types.len) or { 0 }
return types[idx]
}
// 生成随机文件大小
fn generate_random_file_size() i64 {
return rand.i64_in_range(100, 1_000_000) or { 1000 }
}
// 清理测试数据库
fn cleanup_test_db() {
os.rm(test_db_path) or {}
}
// 创建测试数据库管理器
fn create_test_db() !DatabaseManager {
return new_database_manager(test_db_path)
}
// 生成随机 FileInfo
fn generate_random_file_info(bucket string, prefix string) FileInfo {
object_key := if prefix != '' {
prefix + '/' + generate_random_filename()
} else {
generate_random_filename()
}
return FileInfo{
file_hash: generate_random_hash()
file_name: generate_random_filename()
file_size: generate_random_file_size()
file_type: generate_random_content_type()
storage_type: generate_random_storage_type()
bucket: bucket
object_key: object_key
metadata: ''
}
}
// ============================================================================
// Property 7.1: List Returns All Files
// For any set of files inserted into a bucket, list should return all of them
// ============================================================================
fn test_property_7_1_list_returns_all_files() bool {
cleanup_test_db()
defer {
cleanup_test_db()
}
mut db := create_test_db() or {
println(' Failed to create database: ${err}')
return false
}
defer {
db.close()
}
for iteration in 0 .. test_iterations {
// Generate random number of files (1-20)
num_files := rand.int_in_range(1, 21) or { 5 }
bucket := 'test-bucket-${iteration}'
// Insert files and track UUIDs
mut inserted_uuids := []string{}
for _ in 0 .. num_files {
file_info := generate_random_file_info(bucket, '')
inserted := db.insert_file(file_info) or {
println(' Iteration ${iteration}: Insert failed: ${err}')
return false
}
inserted_uuids << inserted.file_uuid
}
// List all files in bucket
result := db.list_files(FileListOptions{
bucket: bucket
limit: 1000
}) or {
println(' Iteration ${iteration}: List failed: ${err}')
return false
}
// Verify count matches
if result.files.len != num_files {
println(' Iteration ${iteration}: Count mismatch. Expected: ${num_files}, Got: ${result.files.len}')
return false
}
// Verify total_count matches
if result.total_count != num_files {
println(' Iteration ${iteration}: Total count mismatch. Expected: ${num_files}, Got: ${result.total_count}')
return false
}
// Verify all inserted UUIDs are in the result
mut found_uuids := []string{}
for file in result.files {
found_uuids << file.file_uuid
}
for uuid in inserted_uuids {
if uuid !in found_uuids {
println(' Iteration ${iteration}: UUID ${uuid} not found in list result')
return false
}
}
}
return true
}
// ============================================================================
// Property 7.2: Pagination Does Not Lose Files
// For any set of files, paginating through all pages should return all files exactly once
// ============================================================================
fn test_property_7_2_pagination_no_loss() bool {
cleanup_test_db()
defer {
cleanup_test_db()
}
mut db := create_test_db() or {
println(' Failed to create database: ${err}')
return false
}
defer {
db.close()
}
for iteration in 0 .. test_iterations {
// Generate random number of files (10-50)
num_files := rand.int_in_range(10, 51) or { 20 }
bucket := 'pagination-bucket-${iteration}'
page_size := rand.int_in_range(3, 10) or { 5 }
// Insert files and track UUIDs
mut inserted_uuids := []string{}
for _ in 0 .. num_files {
file_info := generate_random_file_info(bucket, '')
inserted := db.insert_file(file_info) or {
println(' Iteration ${iteration}: Insert failed: ${err}')
return false
}
inserted_uuids << inserted.file_uuid
}
// Paginate through all files
mut all_found_uuids := []string{}
mut offset := 0
for {
result := db.list_files(FileListOptions{
bucket: bucket
limit: page_size
offset: offset
}) or {
println(' Iteration ${iteration}: List failed at offset ${offset}: ${err}')
return false
}
for file in result.files {
all_found_uuids << file.file_uuid
}
if !result.has_more {
break
}
offset += page_size
}
// Verify all files were found
if all_found_uuids.len != num_files {
println(' Iteration ${iteration}: Total files mismatch. Expected: ${num_files}, Got: ${all_found_uuids.len}')
return false
}
// Verify no duplicates
mut seen := map[string]bool{}
for uuid in all_found_uuids {
if uuid in seen {
println(' Iteration ${iteration}: Duplicate UUID found: ${uuid}')
return false
}
seen[uuid] = true
}
// Verify all inserted UUIDs are found
for uuid in inserted_uuids {
if uuid !in all_found_uuids {
println(' Iteration ${iteration}: UUID ${uuid} not found through pagination')
return false
}
}
}
return true
}
// ============================================================================
// Property 7.3: Prefix Filter Works Correctly
// For any set of files with different prefixes, filtering by prefix should return only matching files
// ============================================================================
fn test_property_7_3_prefix_filter() bool {
cleanup_test_db()
defer {
cleanup_test_db()
}
mut db := create_test_db() or {
println(' Failed to create database: ${err}')
return false
}
defer {
db.close()
}
for iteration in 0 .. test_iterations {
bucket := 'prefix-bucket-${iteration}'
// Create files with different prefixes
prefixes := ['images', 'documents', 'videos', 'data']
mut files_per_prefix := map[string]int{}
for prefix in prefixes {
num_files := rand.int_in_range(2, 8) or { 3 }
files_per_prefix[prefix] = num_files
for _ in 0 .. num_files {
file_info := generate_random_file_info(bucket, prefix)
db.insert_file(file_info) or {
println(' Iteration ${iteration}: Insert failed: ${err}')
return false
}
}
}
// Test filtering by each prefix
for prefix in prefixes {
result := db.list_files(FileListOptions{
bucket: bucket
prefix: prefix
limit: 1000
}) or {
println(' Iteration ${iteration}: List with prefix ${prefix} failed: ${err}')
return false
}
expected_count := files_per_prefix[prefix]
if result.files.len != expected_count {
println(' Iteration ${iteration}: Prefix ${prefix} count mismatch. Expected: ${expected_count}, Got: ${result.files.len}')
return false
}
// Verify all returned files have the correct prefix
for file in result.files {
if !file.object_key.starts_with(prefix) {
println(' Iteration ${iteration}: File ${file.object_key} does not start with prefix ${prefix}')
return false
}
}
}
}
return true
}
// ============================================================================
// Property 7.4: Storage Type Filter Works Correctly
// For any set of files with different storage types, filtering should return only matching files
// ============================================================================
fn test_property_7_4_storage_type_filter() bool {
cleanup_test_db()
defer {
cleanup_test_db()
}
mut db := create_test_db() or {
println(' Failed to create database: ${err}')
return false
}
defer {
db.close()
}
for iteration in 0 .. test_iterations {
bucket := 'storage-type-bucket-${iteration}'
// Create files with different storage types
storage_types := ['local', 's3', 'aliyun_oss', 'tencent_cos']
mut files_per_type := map[string]int{}
for st in storage_types {
num_files := rand.int_in_range(2, 6) or { 3 }
files_per_type[st] = num_files
for _ in 0 .. num_files {
file_info := FileInfo{
file_hash: generate_random_hash()
file_name: generate_random_filename()
file_size: generate_random_file_size()
file_type: generate_random_content_type()
storage_type: st
bucket: bucket
object_key: generate_random_filename()
metadata: ''
}
db.insert_file(file_info) or {
println(' Iteration ${iteration}: Insert failed: ${err}')
return false
}
}
}
// Test filtering by each storage type
for st in storage_types {
result := db.list_files(FileListOptions{
bucket: bucket
storage_type: st
limit: 1000
}) or {
println(' Iteration ${iteration}: List with storage_type ${st} failed: ${err}')
return false
}
expected_count := files_per_type[st]
if result.files.len != expected_count {
println(' Iteration ${iteration}: Storage type ${st} count mismatch. Expected: ${expected_count}, Got: ${result.files.len}')
return false
}
// Verify all returned files have the correct storage type
for file in result.files {
if file.storage_type != st {
println(' Iteration ${iteration}: File has storage_type ${file.storage_type}, expected ${st}')
return false
}
}
}
}
return true
}
// ============================================================================
// Property 7.5: Empty Bucket Returns Empty List
// For any empty bucket, list should return an empty result
// ============================================================================
fn test_property_7_5_empty_bucket() bool {
cleanup_test_db()
defer {
cleanup_test_db()
}
mut db := create_test_db() or {
println(' Failed to create database: ${err}')
return false
}
defer {
db.close()
}
for iteration in 0 .. test_iterations {
// Generate a random bucket name that doesn't exist
bucket := 'empty-bucket-${iteration}-${generate_random_string(5, 10)}'
result := db.list_files(FileListOptions{
bucket: bucket
limit: 100
}) or {
println(' Iteration ${iteration}: List failed: ${err}')
return false
}
if result.files.len != 0 {
println(' Iteration ${iteration}: Expected empty list, got ${result.files.len} files')
return false
}
if result.total_count != 0 {
println(' Iteration ${iteration}: Expected total_count 0, got ${result.total_count}')
return false
}
if result.has_more {
println(' Iteration ${iteration}: has_more should be false for empty bucket')
return false
}
}
return true
}
fn main() {
println('🚀 开始 List Operation Completeness 属性测试...')
println('Feature: v-hono-upload-integration, Property 7: List Operation Completeness')
println('Validates: Requirements 8.5')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(67890)])
mut stats := PropertyTestStats{}
// Run property tests
stats.run_property_test('Property 7.1: List Returns All Files', test_property_7_1_list_returns_all_files)
stats.run_property_test('Property 7.2: Pagination Does Not Lose Files', test_property_7_2_pagination_no_loss)
stats.run_property_test('Property 7.3: Prefix Filter Works Correctly', test_property_7_3_prefix_filter)
stats.run_property_test('Property 7.4: Storage Type Filter Works Correctly', test_property_7_4_storage_type_filter)
stats.run_property_test('Property 7.5: Empty Bucket Returns Empty List', test_property_7_5_empty_bucket)
// Print summary
stats.print_summary()
// Cleanup
cleanup_test_db()
// Exit with error code if any tests failed
if stats.failed_tests > 0 {
exit(1)
}
}
@@ -0,0 +1,630 @@
module main
import rand
import time
import os
import crypto.md5
// ============================================================================
// Property 6: File Existence Consistency
// Feature: v-hono-upload-integration, Property 6: File Existence Consistency
// Validates: Requirements 2.2, 8.4
//
// *For any* file, after upload the `exists()` method should return true,
// and after deletion it should return false.
// ============================================================================
const test_iterations = 100
const test_base_path = './test_storage_existence'
const test_bucket = 'test-bucket'
// ============================================================================
// Type definitions (copied from storage modules for standalone testing)
// ============================================================================
struct StorageResult {
pub:
success bool
object_key string
etag string
size i64
error_msg string
}
struct ObjectInfo {
pub:
key string
size i64
etag string
content_type string
last_modified i64
metadata map[string]string
}
struct PartInfo {
pub:
part_number int
etag string
size i64
}
struct LocalStorageConfig {
pub:
base_path string = './storage'
url_prefix string = '/files'
create_dirs bool = true
}
// LocalStorage 本地存储提供者
struct LocalStorage {
config LocalStorageConfig
mut:
multipart_uploads map[string]MultipartUploadState
}
struct MultipartUploadState {
mut:
bucket string
key string
content_type string
parts map[int]PartInfo
created_at i64
}
fn new_local_storage(config LocalStorageConfig) !LocalStorage {
if config.create_dirs {
os.mkdir_all(config.base_path) or {
return error('Failed to create base directory: ${err}')
}
}
return LocalStorage{
config: config
multipart_uploads: map[string]MultipartUploadState{}
}
}
fn (s LocalStorage) get_full_path(bucket string, key string) string {
return os.join_path(s.config.base_path, bucket, key)
}
fn (s LocalStorage) get_bucket_path(bucket string) string {
return os.join_path(s.config.base_path, bucket)
}
fn calculate_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
fn new_storage_result(object_key string, etag string, size i64) StorageResult {
return StorageResult{
success: true
object_key: object_key
etag: etag
size: size
error_msg: ''
}
}
fn new_object_info(key string, size i64, etag string, content_type string, last_modified i64) ObjectInfo {
return ObjectInfo{
key: key
size: size
etag: etag
content_type: content_type
last_modified: last_modified
metadata: map[string]string{}
}
}
// ============================================================================
// LocalStorage implementation (copied for standalone testing)
// ============================================================================
fn (mut s LocalStorage) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket directory: ${err}')
}
}
full_path := s.get_full_path(bucket, key)
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error('Failed to create parent directory: ${err}')
}
}
os.write_file_array(full_path, data) or {
return error('Failed to write file: ${err}')
}
etag := calculate_etag(data)
return new_storage_result(key, etag, i64(data.len))
}
fn (s LocalStorage) delete(bucket string, key string) ! {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
os.rm(full_path) or {
return error('Failed to delete file: ${err}')
}
}
fn (s LocalStorage) exists(bucket string, key string) !bool {
full_path := s.get_full_path(bucket, key)
return os.exists(full_path) && os.is_file(full_path)
}
fn (s LocalStorage) create_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if os.exists(bucket_path) {
return
}
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket: ${err}')
}
}
fn (s LocalStorage) delete_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if !os.exists(bucket_path) {
return error('Bucket not found: ${bucket}')
}
entries := os.ls(bucket_path) or { []string{} }
if entries.len > 0 {
return error('Bucket is not empty')
}
os.rmdir(bucket_path) or {
return error('Failed to delete bucket: ${err}')
}
}
fn (s LocalStorage) bucket_exists(bucket string) !bool {
bucket_path := s.get_bucket_path(bucket)
return os.exists(bucket_path) && os.is_dir(bucket_path)
}
fn (s LocalStorage) head(bucket string, key string) !ObjectInfo {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
file_size := os.file_size(full_path)
mtime := os.file_last_mod_unix(full_path)
data := os.read_bytes(full_path) or {
return error('Failed to read file for metadata: ${err}')
}
etag := calculate_etag(data)
content_type := infer_content_type(key)
return new_object_info(key, i64(file_size), etag, content_type, mtime)
}
fn infer_content_type(key string) string {
ext := os.file_ext(key).to_lower()
match ext {
'.html', '.htm' { return 'text/html' }
'.css' { return 'text/css' }
'.js' { return 'application/javascript' }
'.json' { return 'application/json' }
'.xml' { return 'application/xml' }
'.txt' { return 'text/plain' }
'.png' { return 'image/png' }
'.jpg', '.jpeg' { return 'image/jpeg' }
'.gif' { return 'image/gif' }
'.pdf' { return 'application/pdf' }
'.zip' { return 'application/zip' }
else { return 'application/octet-stream' }
}
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== File Existence Consistency 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
fn generate_random_bytes(min_len int, max_len int) []u8 {
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := []u8{len: len}
for i in 0 .. len {
result[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_random_filename() string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(5, 20) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
extensions := ['.txt', '.bin', '.dat', '.json', '.xml']
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
return result + extensions[ext_idx]
}
fn cleanup_test_dir() {
os.rmdir_all(test_base_path) or {}
}
fn create_test_storage() !LocalStorage {
config := LocalStorageConfig{
base_path: test_base_path
url_prefix: '/files'
create_dirs: true
}
return new_local_storage(config)
}
// ============================================================================
// Property 6.1: File Exists After Upload
// For any file, after upload the exists() method should return true
// ============================================================================
fn test_property_6_1_exists_after_upload() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(1, 5000)
filename := generate_random_filename()
exists_before := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed before upload: ${err}')
return false
}
if exists_before {
println(' Iteration ${i}: File exists before upload')
return false
}
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Upload returned failure')
return false
}
exists_after := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed after upload: ${err}')
return false
}
if !exists_after {
println(' Iteration ${i}: File does not exist after upload')
return false
}
storage.delete(test_bucket, filename) or {}
}
return true
}
// ============================================================================
// Property 6.2: File Does Not Exist After Delete
// For any file, after deletion the exists() method should return false
// ============================================================================
fn test_property_6_2_not_exists_after_delete() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(1, 5000)
filename := generate_random_filename()
storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
exists_before_delete := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed before delete: ${err}')
return false
}
if !exists_before_delete {
println(' Iteration ${i}: File does not exist before delete')
return false
}
storage.delete(test_bucket, filename) or {
println(' Iteration ${i}: Delete failed: ${err}')
return false
}
exists_after_delete := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed after delete: ${err}')
return false
}
if exists_after_delete {
println(' Iteration ${i}: File still exists after delete')
return false
}
}
return true
}
// ============================================================================
// Property 6.3: Non-Existent File Returns False
// For any random filename that was never uploaded, exists() should return false
// ============================================================================
fn test_property_6_3_non_existent_file() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
filename := generate_random_filename()
exists := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed: ${err}')
return false
}
if exists {
println(' Iteration ${i}: Non-existent file reported as existing')
return false
}
}
return true
}
// ============================================================================
// Property 6.4: Existence Consistency with Head Operation
// For any file, exists() and head() should be consistent
// ============================================================================
fn test_property_6_4_existence_head_consistency() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(1, 5000)
filename := generate_random_filename()
storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
exists := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed: ${err}')
return false
}
info := storage.head(test_bucket, filename) or {
println(' Iteration ${i}: head() failed but exists() returned ${exists}: ${err}')
return false
}
if !exists {
println(' Iteration ${i}: exists() returned false but head() succeeded')
return false
}
if info.size != i64(data.len) {
println(' Iteration ${i}: head() returned wrong size. Expected: ${data.len}, Got: ${info.size}')
return false
}
storage.delete(test_bucket, filename) or {}
exists_after := storage.exists(test_bucket, filename) or {
println(' Iteration ${i}: exists() failed after delete: ${err}')
return false
}
if exists_after {
println(' Iteration ${i}: exists() returned true after delete')
return false
}
storage.head(test_bucket, filename) or {
continue
}
println(' Iteration ${i}: head() succeeded after delete')
return false
}
return true
}
// ============================================================================
// Property 6.5: Bucket Existence Consistency
// For any bucket, bucket_exists() should be consistent with create/delete operations
// ============================================================================
fn test_property_6_5_bucket_existence_consistency() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
for i in 0 .. test_iterations {
bucket_name := 'test-bucket-${i}-${rand.int_in_range(1000, 9999) or { 1000 }}'
exists_before := storage.bucket_exists(bucket_name) or {
println(' Iteration ${i}: bucket_exists() failed before create: ${err}')
return false
}
if exists_before {
println(' Iteration ${i}: Bucket exists before create')
return false
}
storage.create_bucket(bucket_name) or {
println(' Iteration ${i}: create_bucket() failed: ${err}')
return false
}
exists_after_create := storage.bucket_exists(bucket_name) or {
println(' Iteration ${i}: bucket_exists() failed after create: ${err}')
return false
}
if !exists_after_create {
println(' Iteration ${i}: Bucket does not exist after create')
return false
}
storage.delete_bucket(bucket_name) or {
println(' Iteration ${i}: delete_bucket() failed: ${err}')
return false
}
exists_after_delete := storage.bucket_exists(bucket_name) or {
println(' Iteration ${i}: bucket_exists() failed after delete: ${err}')
return false
}
if exists_after_delete {
println(' Iteration ${i}: Bucket still exists after delete')
return false
}
}
return true
}
fn main() {
println('🚀 开始 File Existence Consistency 属性测试...')
println('Feature: v-hono-upload-integration, Property 6: File Existence Consistency')
println('Validates: Requirements 2.2, 8.4')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(67890)])
mut stats := PropertyTestStats{}
stats.run_property_test('Property 6.1: File Exists After Upload', test_property_6_1_exists_after_upload)
stats.run_property_test('Property 6.2: File Does Not Exist After Delete', test_property_6_2_not_exists_after_delete)
stats.run_property_test('Property 6.3: Non-Existent File Returns False', test_property_6_3_non_existent_file)
stats.run_property_test('Property 6.4: Existence Consistency with Head Operation', test_property_6_4_existence_head_consistency)
stats.run_property_test('Property 6.5: Bucket Existence Consistency', test_property_6_5_bucket_existence_consistency)
stats.print_summary()
cleanup_test_dir()
if stats.failed_tests > 0 {
exit(1)
}
}
@@ -0,0 +1,658 @@
module main
import rand
import time
import os
import crypto.md5
// ============================================================================
// Property 1: Upload-Download Round Trip (Local)
// Feature: v-hono-upload-integration, Property 1: Upload-Download Round Trip
// Validates: Requirements 1.2, 2.3
//
// *For any* valid file data and any storage provider (Local, S3, OSS, COS),
// uploading the file and then downloading it should return identical content.
// ============================================================================
const test_iterations = 100
const test_base_path = './test_storage_roundtrip'
const test_bucket = 'test-bucket'
// ============================================================================
// Type definitions (copied from storage modules for standalone testing)
// ============================================================================
struct StorageResult {
pub:
success bool
object_key string
etag string
size i64
error_msg string
}
struct ObjectInfo {
pub:
key string
size i64
etag string
content_type string
last_modified i64
metadata map[string]string
}
struct ListOptions {
pub:
prefix string
delimiter string
max_keys int = 1000
start_after string
}
struct ListResult {
pub:
objects []ObjectInfo
common_prefixes []string
is_truncated bool
next_marker string
}
struct PresignOptions {
pub:
expires_in int = 3600
method string = 'GET'
content_type string
}
struct PartInfo {
pub:
part_number int
etag string
size i64
}
struct LocalStorageConfig {
pub:
base_path string = './storage'
url_prefix string = '/files'
create_dirs bool = true
}
// LocalStorage 本地存储提供者
struct LocalStorage {
config LocalStorageConfig
mut:
multipart_uploads map[string]MultipartUploadState
}
struct MultipartUploadState {
mut:
bucket string
key string
content_type string
parts map[int]PartInfo
created_at i64
}
// 创建本地存储提供者
fn new_local_storage(config LocalStorageConfig) !LocalStorage {
if config.create_dirs {
os.mkdir_all(config.base_path) or {
return error('Failed to create base directory: ${err}')
}
}
return LocalStorage{
config: config
multipart_uploads: map[string]MultipartUploadState{}
}
}
fn (s LocalStorage) get_full_path(bucket string, key string) string {
return os.join_path(s.config.base_path, bucket, key)
}
fn (s LocalStorage) get_bucket_path(bucket string) string {
return os.join_path(s.config.base_path, bucket)
}
fn calculate_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
fn new_storage_result(object_key string, etag string, size i64) StorageResult {
return StorageResult{
success: true
object_key: object_key
etag: etag
size: size
error_msg: ''
}
}
fn new_object_info(key string, size i64, etag string, content_type string, last_modified i64) ObjectInfo {
return ObjectInfo{
key: key
size: size
etag: etag
content_type: content_type
last_modified: last_modified
metadata: map[string]string{}
}
}
fn new_list_result(objects []ObjectInfo, common_prefixes []string, is_truncated bool, next_marker string) ListResult {
return ListResult{
objects: objects
common_prefixes: common_prefixes
is_truncated: is_truncated
next_marker: next_marker
}
}
fn new_part_info(part_number int, etag string, size i64) PartInfo {
return PartInfo{
part_number: part_number
etag: etag
size: size
}
}
// ============================================================================
// LocalStorage implementation (copied for standalone testing)
// ============================================================================
fn (mut s LocalStorage) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket directory: ${err}')
}
}
full_path := s.get_full_path(bucket, key)
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error('Failed to create parent directory: ${err}')
}
}
os.write_file_array(full_path, data) or {
return error('Failed to write file: ${err}')
}
etag := calculate_etag(data)
return new_storage_result(key, etag, i64(data.len))
}
fn (s LocalStorage) download(bucket string, key string) ![]u8 {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
data := os.read_bytes(full_path) or {
return error('Failed to read file: ${err}')
}
return data
}
fn (s LocalStorage) delete(bucket string, key string) ! {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
os.rm(full_path) or {
return error('Failed to delete file: ${err}')
}
}
fn (s LocalStorage) exists(bucket string, key string) !bool {
full_path := s.get_full_path(bucket, key)
return os.exists(full_path) && os.is_file(full_path)
}
fn (s LocalStorage) create_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if os.exists(bucket_path) {
return
}
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket: ${err}')
}
}
fn (s LocalStorage) head(bucket string, key string) !ObjectInfo {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
file_size := os.file_size(full_path)
mtime := os.file_last_mod_unix(full_path)
data := os.read_bytes(full_path) or {
return error('Failed to read file for metadata: ${err}')
}
etag := calculate_etag(data)
content_type := infer_content_type(key)
return new_object_info(key, i64(file_size), etag, content_type, mtime)
}
fn infer_content_type(key string) string {
ext := os.file_ext(key).to_lower()
match ext {
'.html', '.htm' { return 'text/html' }
'.css' { return 'text/css' }
'.js' { return 'application/javascript' }
'.json' { return 'application/json' }
'.xml' { return 'application/xml' }
'.txt' { return 'text/plain' }
'.png' { return 'image/png' }
'.jpg', '.jpeg' { return 'image/jpeg' }
'.gif' { return 'image/gif' }
'.pdf' { return 'application/pdf' }
'.zip' { return 'application/zip' }
else { return 'application/octet-stream' }
}
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_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 PropertyTestStats) print_summary() {
println('\n=== Upload-Download Round Trip 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
fn generate_random_bytes(min_len int, max_len int) []u8 {
len := rand.int_in_range(min_len, max_len) or { min_len }
mut result := []u8{len: len}
for i in 0 .. len {
result[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_random_filename() string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(5, 20) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
extensions := ['.txt', '.bin', '.dat', '.json', '.xml']
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
return result + extensions[ext_idx]
}
fn generate_random_content_type() string {
types := [
'text/plain',
'application/octet-stream',
'application/json',
'image/png',
'application/xml',
]
idx := rand.int_in_range(0, types.len) or { 0 }
return types[idx]
}
fn cleanup_test_dir() {
os.rmdir_all(test_base_path) or {}
}
fn create_test_storage() !LocalStorage {
config := LocalStorageConfig{
base_path: test_base_path
url_prefix: '/files'
create_dirs: true
}
return new_local_storage(config)
}
// ============================================================================
// Property 1.1: Basic Upload-Download Round Trip
// For any random file data, uploading and downloading should return identical content
// ============================================================================
fn test_property_1_1_basic_roundtrip() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(1, 10000)
filename := generate_random_filename()
content_type := generate_random_content_type()
result := storage.upload(test_bucket, filename, data, content_type) or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Upload returned failure: ${result.error_msg}')
return false
}
downloaded := storage.download(test_bucket, filename) or {
println(' Iteration ${i}: Download failed: ${err}')
return false
}
if data != downloaded {
println(' Iteration ${i}: Content mismatch! Original: ${data.len} bytes, Downloaded: ${downloaded.len} bytes')
return false
}
storage.delete(test_bucket, filename) or {}
}
return true
}
// ============================================================================
// Property 1.2: Empty File Round Trip
// For empty files, uploading and downloading should return empty content
// ============================================================================
fn test_property_1_2_empty_file_roundtrip() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := []u8{}
filename := generate_random_filename()
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Upload returned failure')
return false
}
downloaded := storage.download(test_bucket, filename) or {
println(' Iteration ${i}: Download failed: ${err}')
return false
}
if downloaded.len != 0 {
println(' Iteration ${i}: Expected empty file, got ${downloaded.len} bytes')
return false
}
storage.delete(test_bucket, filename) or {}
}
return true
}
// ============================================================================
// Property 1.3: Large File Round Trip
// For larger files (up to 500KB), uploading and downloading should return identical content
// ============================================================================
fn test_property_1_3_large_file_roundtrip() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
// Run fewer iterations for large files
for i in 0 .. 10 {
data := generate_random_bytes(100000, 500000)
filename := generate_random_filename()
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Upload returned failure')
return false
}
if result.size != i64(data.len) {
println(' Iteration ${i}: Size mismatch in result. Expected: ${data.len}, Got: ${result.size}')
return false
}
downloaded := storage.download(test_bucket, filename) or {
println(' Iteration ${i}: Download failed: ${err}')
return false
}
if data != downloaded {
println(' Iteration ${i}: Content mismatch! Original: ${data.len} bytes, Downloaded: ${downloaded.len} bytes')
return false
}
storage.delete(test_bucket, filename) or {}
}
return true
}
// ============================================================================
// Property 1.4: Nested Path Round Trip
// For files with nested paths, uploading and downloading should work correctly
// ============================================================================
fn test_property_1_4_nested_path_roundtrip() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(100, 5000)
depth := rand.int_in_range(1, 5) or { 2 }
mut path_parts := []string{}
for _ in 0 .. depth {
part_len := rand.int_in_range(3, 10) or { 5 }
mut part := ''
for _ in 0 .. part_len {
idx := rand.int_in_range(0, 26) or { 0 }
part += ('a'[0] + u8(idx)).ascii_str()
}
path_parts << part
}
path_parts << generate_random_filename()
filename := path_parts.join('/')
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed for path "${filename}": ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Upload returned failure')
return false
}
downloaded := storage.download(test_bucket, filename) or {
println(' Iteration ${i}: Download failed for path "${filename}": ${err}')
return false
}
if data != downloaded {
println(' Iteration ${i}: Content mismatch for path "${filename}"')
return false
}
storage.delete(test_bucket, filename) or {}
}
return true
}
// ============================================================================
// Property 1.5: ETag Consistency
// For any file, the ETag returned on upload should be consistent with the content
// ============================================================================
fn test_property_1_5_etag_consistency() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage := create_test_storage() or {
println(' Failed to create storage: ${err}')
return false
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
for i in 0 .. test_iterations {
data := generate_random_bytes(100, 5000)
filename := generate_random_filename()
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
println(' Iteration ${i}: Upload failed: ${err}')
return false
}
if result.etag == '' {
println(' Iteration ${i}: ETag is empty')
return false
}
filename2 := generate_random_filename()
result2 := storage.upload(test_bucket, filename2, data, 'application/octet-stream') or {
println(' Iteration ${i}: Second upload failed: ${err}')
return false
}
if result.etag != result2.etag {
println(' Iteration ${i}: ETags differ for same content: ${result.etag} vs ${result2.etag}')
return false
}
storage.delete(test_bucket, filename) or {}
storage.delete(test_bucket, filename2) or {}
}
return true
}
fn main() {
println('🚀 开始 Upload-Download Round Trip 属性测试...')
println('Feature: v-hono-upload-integration, Property 1: Upload-Download Round Trip (Local)')
println('Validates: Requirements 1.2, 2.3')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(54321)])
mut stats := PropertyTestStats{}
stats.run_property_test('Property 1.1: Basic Upload-Download Round Trip', test_property_1_1_basic_roundtrip)
stats.run_property_test('Property 1.2: Empty File Round Trip', test_property_1_2_empty_file_roundtrip)
stats.run_property_test('Property 1.3: Large File Round Trip', test_property_1_3_large_file_roundtrip)
stats.run_property_test('Property 1.4: Nested Path Round Trip', test_property_1_4_nested_path_roundtrip)
stats.run_property_test('Property 1.5: ETag Consistency', test_property_1_5_etag_consistency)
stats.print_summary()
cleanup_test_dir()
if stats.failed_tests > 0 {
exit(1)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import hono
import meiseayoung.hono
import os
import time

Some files were not shown because too many files have changed in this diff Show More