| 1 | // vtest build: !windows // fasthttp.Server.run is not implemented on windows yet |
| 2 | import veb |
| 3 | import net.http |
| 4 | import os |
| 5 | import time |
| 6 | import compress.gzip |
| 7 | import compress.zstd |
| 8 | |
| 9 | const port = 13001 |
| 10 | |
| 11 | const localserver = 'http://127.0.0.1:${port}' |
| 12 | |
| 13 | const exit_after = time.second * 10 |
| 14 | |
| 15 | pub struct Context { |
| 16 | veb.Context |
| 17 | pub mut: |
| 18 | counter int |
| 19 | bound_middleware_tag string |
| 20 | } |
| 21 | |
| 22 | @[heap] |
| 23 | pub struct App { |
| 24 | veb.Middleware[Context] |
| 25 | mut: |
| 26 | started chan bool |
| 27 | bound_middleware_hits int |
| 28 | bound_middleware_value string |
| 29 | } |
| 30 | |
| 31 | pub fn (mut app App) before_accept_loop() { |
| 32 | app.started <- true |
| 33 | } |
| 34 | |
| 35 | pub fn (app &App) index(mut ctx Context) veb.Result { |
| 36 | return ctx.text('from index, ${ctx.counter}') |
| 37 | } |
| 38 | |
| 39 | @['/bar/bar'] |
| 40 | pub fn (app &App) bar(mut ctx Context) veb.Result { |
| 41 | return ctx.text('from bar, ${ctx.counter}') |
| 42 | } |
| 43 | |
| 44 | pub fn (app &App) unreachable(mut ctx Context) veb.Result { |
| 45 | return ctx.text('should never be reachable!') |
| 46 | } |
| 47 | |
| 48 | @['/nested/route/method'] |
| 49 | pub fn (app &App) nested(mut ctx Context) veb.Result { |
| 50 | return ctx.text('from nested, ${ctx.counter}') |
| 51 | } |
| 52 | |
| 53 | pub fn (app &App) after(mut ctx Context) veb.Result { |
| 54 | return ctx.text('from after, ${ctx.counter}') |
| 55 | } |
| 56 | |
| 57 | @['/admin/auth'; get] |
| 58 | pub fn (app &App) admin_auth_get(mut ctx Context) veb.Result { |
| 59 | return ctx.text('protected get route') |
| 60 | } |
| 61 | |
| 62 | @['/admin/auth'; post] |
| 63 | pub fn (app &App) admin_auth_post(mut ctx Context) veb.Result { |
| 64 | return ctx.text('public post route') |
| 65 | } |
| 66 | |
| 67 | @['/gzip'] |
| 68 | pub fn (app &App) gzip_test(mut ctx Context) veb.Result { |
| 69 | return ctx.text('gzip response, ${ctx.counter}') |
| 70 | } |
| 71 | |
| 72 | @['/decode_gzip'; post] |
| 73 | pub fn (app &App) decode_gzip_test(mut ctx Context) veb.Result { |
| 74 | return ctx.text('received: ${ctx.req.data}') |
| 75 | } |
| 76 | |
| 77 | @['/zstd'] |
| 78 | pub fn (app &App) zstd_test(mut ctx Context) veb.Result { |
| 79 | return ctx.text('zstd response, ${ctx.counter}') |
| 80 | } |
| 81 | |
| 82 | @['/decode_zstd'; post] |
| 83 | pub fn (app &App) decode_zstd_test(mut ctx Context) veb.Result { |
| 84 | return ctx.text('received: ${ctx.req.data}') |
| 85 | } |
| 86 | |
| 87 | @['/content'] |
| 88 | pub fn (app &App) content_test(mut ctx Context) veb.Result { |
| 89 | return ctx.text('content response, ${ctx.counter}') |
| 90 | } |
| 91 | |
| 92 | // Test route for double-send regression test |
| 93 | @['/double_after'] |
| 94 | pub fn (app &App) double_after(mut ctx Context) veb.Result { |
| 95 | return ctx.text('handler response') |
| 96 | } |
| 97 | |
| 98 | // bound verifies that route middleware can read app state before the handler runs. |
| 99 | @['/bound'] |
| 100 | pub fn (app &App) bound(mut ctx Context) veb.Result { |
| 101 | return ctx.text('${app.bound_middleware_value}, ${app.bound_middleware_hits}, ${ctx.bound_middleware_tag}, ${ctx.counter}') |
| 102 | } |
| 103 | |
| 104 | pub fn (app &App) app_middleware(mut ctx Context) bool { |
| 105 | ctx.counter++ |
| 106 | return true |
| 107 | } |
| 108 | |
| 109 | pub fn (app &App) before_request(mut ctx Context) bool { |
| 110 | ctx.res.header.add_custom('X-BEFORE-REQUEST-MIDDLEWARE', 'true') or { panic(err) } |
| 111 | return true |
| 112 | } |
| 113 | |
| 114 | // route_bound_middleware reads and mutates the bound app to exercise stateful middleware. |
| 115 | pub fn (mut app App) route_bound_middleware(mut ctx Context) bool { |
| 116 | app.bound_middleware_hits++ |
| 117 | ctx.bound_middleware_tag = app.bound_middleware_value |
| 118 | ctx.counter += 10 |
| 119 | return true |
| 120 | } |
| 121 | |
| 122 | fn middleware_handler(mut ctx Context) bool { |
| 123 | ctx.counter++ |
| 124 | return true |
| 125 | } |
| 126 | |
| 127 | fn middleware_unreachable(mut ctx Context) bool { |
| 128 | ctx.text('unreachable, ${ctx.counter}') |
| 129 | return false |
| 130 | } |
| 131 | |
| 132 | fn after_middleware(mut ctx Context) bool { |
| 133 | ctx.counter++ |
| 134 | ctx.res.header.add_custom('X-AFTER', ctx.counter.str()) or { panic('bad') } |
| 135 | return true |
| 136 | } |
| 137 | |
| 138 | fn auth_required_middleware(mut ctx Context) bool { |
| 139 | ctx.res.set_status(.unauthorized) |
| 140 | ctx.text('auth required') |
| 141 | return false |
| 142 | } |
| 143 | |
| 144 | // Global after-middleware that sends a response (for double-send regression test) |
| 145 | // Only sends for /double_after route to avoid breaking other tests |
| 146 | fn global_after_sends_response(mut ctx Context) bool { |
| 147 | if ctx.req.url.starts_with('/double_after') { |
| 148 | ctx.text('global after response') |
| 149 | } |
| 150 | return true |
| 151 | } |
| 152 | |
| 153 | // Route after-middleware that also tries to send (should be skipped if global already sent) |
| 154 | fn route_after_sends_response(mut ctx Context) bool { |
| 155 | // Add header to prove this middleware ran (for regression test) |
| 156 | ctx.res.header.add_custom('X-ROUTE-AFTER-RAN', 'true') or {} |
| 157 | ctx.text('route after response - should not appear') |
| 158 | return true |
| 159 | } |
| 160 | |
| 161 | fn testsuite_begin() { |
| 162 | os.chdir(os.dir(@FILE))! |
| 163 | |
| 164 | mut app := &App{ |
| 165 | bound_middleware_value: 'from app middleware' |
| 166 | } |
| 167 | // even though `route_use` is called first, global middleware is still executed first |
| 168 | app.Middleware.route_use('/unreachable', handler: middleware_unreachable) |
| 169 | |
| 170 | // global middleware |
| 171 | app.Middleware.use(handler: middleware_handler) |
| 172 | app.Middleware.use(handler: app.app_middleware) |
| 173 | app.Middleware.use(handler: app.before_request) |
| 174 | |
| 175 | // should match only one slash |
| 176 | app.Middleware.route_use('/bar/:foo', handler: middleware_handler) |
| 177 | // should match multiple slashes |
| 178 | app.Middleware.route_use('/nested/:path...', handler: middleware_handler) |
| 179 | app.Middleware.route_use('/bound', handler: app.route_bound_middleware) |
| 180 | |
| 181 | app.Middleware.route_use('/after', handler: after_middleware, after: true) |
| 182 | app.Middleware.route_use('/admin/auth', |
| 183 | handler: auth_required_middleware |
| 184 | methods: [ |
| 185 | .get, |
| 186 | ] |
| 187 | ) |
| 188 | |
| 189 | // Gzip middleware tests |
| 190 | app.Middleware.use(veb.decode_gzip[Context]()) |
| 191 | app.Middleware.route_use('/gzip', veb.encode_gzip[Context]()) |
| 192 | |
| 193 | // Zstd middleware tests |
| 194 | app.Middleware.use(veb.decode_zstd[Context]()) |
| 195 | app.Middleware.route_use('/zstd', veb.encode_zstd[Context]()) |
| 196 | |
| 197 | // Auto content encoding middleware (zstd/gzip based on Accept-Encoding) |
| 198 | app.Middleware.route_use('/content', veb.encode_auto[Context]()) |
| 199 | |
| 200 | // Regression test: global after-middleware sends response, route after-middleware should be skipped |
| 201 | app.Middleware.use(handler: global_after_sends_response, after: true) |
| 202 | app.Middleware.route_use('/double_after', handler: route_after_sends_response, after: true) |
| 203 | |
| 204 | spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) |
| 205 | // app startup time |
| 206 | _ := <-app.started |
| 207 | |
| 208 | spawn fn () { |
| 209 | time.sleep(exit_after) |
| 210 | assert true == false, 'timeout reached!' |
| 211 | exit(1) |
| 212 | }() |
| 213 | } |
| 214 | |
| 215 | fn test_index() { |
| 216 | x := http.get(localserver)! |
| 217 | assert x.body == 'from index, 2' |
| 218 | } |
| 219 | |
| 220 | fn test_bound_before_request_named_middleware_does_not_timeout() { |
| 221 | x := http.fetch(http.FetchConfig{ |
| 222 | url: localserver |
| 223 | read_timeout: 1500 * time.millisecond |
| 224 | write_timeout: 1500 * time.millisecond |
| 225 | })! |
| 226 | assert x.body == 'from index, 2' |
| 227 | assert x.header.get_custom('X-BEFORE-REQUEST-MIDDLEWARE')! == 'true' |
| 228 | } |
| 229 | |
| 230 | fn test_unreachable_order() { |
| 231 | x := http.get('${localserver}/unreachable')! |
| 232 | assert x.body == 'unreachable, 2' |
| 233 | } |
| 234 | |
| 235 | fn test_dynamic_route() { |
| 236 | x := http.get('${localserver}/bar/bar')! |
| 237 | assert x.body == 'from bar, 3' |
| 238 | } |
| 239 | |
| 240 | fn test_nested() { |
| 241 | x := http.get('${localserver}/nested/route/method')! |
| 242 | assert x.body == 'from nested, 3' |
| 243 | } |
| 244 | |
| 245 | fn test_route_middleware_bound_method_can_access_app_state() { |
| 246 | x := http.get('${localserver}/bound')! |
| 247 | assert x.body == 'from app middleware, 1, from app middleware, 12' |
| 248 | } |
| 249 | |
| 250 | fn test_route_middleware_method_filter_get() { |
| 251 | x := http.get('${localserver}/admin/auth')! |
| 252 | assert x.status() == .unauthorized |
| 253 | assert x.body == 'auth required' |
| 254 | } |
| 255 | |
| 256 | fn test_route_middleware_method_filter_post() { |
| 257 | x := http.fetch(http.FetchConfig{ |
| 258 | url: '${localserver}/admin/auth' |
| 259 | method: .post |
| 260 | })! |
| 261 | assert x.status() == .ok |
| 262 | assert x.body == 'public post route' |
| 263 | } |
| 264 | |
| 265 | fn test_after_middleware() { |
| 266 | x := http.get('${localserver}/after')! |
| 267 | assert x.body == 'from after, 2' |
| 268 | |
| 269 | custom_header := x.header.get_custom('X-AFTER') or { panic('should be set!') } |
| 270 | assert custom_header == '3' |
| 271 | } |
| 272 | |
| 273 | // Verifies that encode_gzip compresses responses |
| 274 | fn test_encode_gzip_middleware() { |
| 275 | x := http.get('${localserver}/gzip')! |
| 276 | |
| 277 | encoding := x.header.get(.content_encoding) or { '' } |
| 278 | assert encoding == 'gzip', 'Expected gzip encoding, got: ${encoding}' |
| 279 | |
| 280 | // HTTP client auto-decompresses gzip content |
| 281 | assert x.body == 'gzip response, 2' |
| 282 | } |
| 283 | |
| 284 | // Verifies that decode_gzip middleware decompresses request bodies |
| 285 | fn test_decode_gzip_middleware() { |
| 286 | original_text := 'Hello from gzip compressed body!' |
| 287 | compressed := gzip.compress(original_text.bytes())! |
| 288 | |
| 289 | mut req := http.new_request(.post, '${localserver}/decode_gzip', compressed.bytestr()) |
| 290 | req.header.add(.content_encoding, 'gzip') |
| 291 | x := req.do()! |
| 292 | |
| 293 | assert x.body == 'received: ${original_text}' |
| 294 | } |
| 295 | |
| 296 | // Verifies that encode_zstd compresses responses |
| 297 | fn test_encode_zstd_middleware() { |
| 298 | x := http.get('${localserver}/zstd')! |
| 299 | |
| 300 | encoding := x.header.get(.content_encoding) or { '' } |
| 301 | assert encoding == 'zstd', 'Expected zstd encoding, got: ${encoding}' |
| 302 | |
| 303 | decompressed := zstd.decompress(x.body.bytes())! |
| 304 | assert decompressed.bytestr() == 'zstd response, 2' |
| 305 | } |
| 306 | |
| 307 | // Verifies that decode_zstd middleware decompresses request bodies |
| 308 | fn test_decode_zstd_middleware() { |
| 309 | original_text := 'Hello from zstd compressed body!' |
| 310 | compressed := zstd.compress(original_text.bytes())! |
| 311 | |
| 312 | mut req := http.new_request(.post, '${localserver}/decode_zstd', compressed.bytestr()) |
| 313 | req.header.add(.content_encoding, 'zstd') |
| 314 | x := req.do()! |
| 315 | |
| 316 | assert x.body == 'received: ${original_text}' |
| 317 | } |
| 318 | |
| 319 | // Verifies that encode_auto uses zstd when client supports it |
| 320 | fn test_encode_auto_zstd() { |
| 321 | mut req := http.new_request(.get, '${localserver}/content', '') |
| 322 | req.header.add(.accept_encoding, 'gzip, zstd, br') |
| 323 | x := req.do()! |
| 324 | |
| 325 | encoding := x.header.get(.content_encoding) or { '' } |
| 326 | assert encoding == 'zstd', 'Expected zstd encoding when zstd is in Accept-Encoding, got: ${encoding}' |
| 327 | |
| 328 | decompressed := zstd.decompress(x.body.bytes())! |
| 329 | assert decompressed.bytestr() == 'content response, 2' |
| 330 | } |
| 331 | |
| 332 | // Verifies that encode_auto falls back to gzip when client doesn't support zstd |
| 333 | fn test_encode_auto_gzip_fallback() { |
| 334 | mut req := http.new_request(.get, '${localserver}/content', '') |
| 335 | req.header.add(.accept_encoding, 'gzip, br') |
| 336 | x := req.do()! |
| 337 | |
| 338 | encoding := x.header.get(.content_encoding) or { '' } |
| 339 | assert encoding == 'gzip', 'Expected gzip encoding when zstd is not in Accept-Encoding, got: ${encoding}' |
| 340 | |
| 341 | // HTTP client auto-decompresses gzip content |
| 342 | assert x.body == 'content response, 2' |
| 343 | } |
| 344 | |
| 345 | // Verifies that encode_auto sends uncompressed when no encoding is supported |
| 346 | fn test_encode_auto_no_compression() { |
| 347 | mut req := http.new_request(.get, '${localserver}/content', '') |
| 348 | req.header.add(.accept_encoding, 'br') |
| 349 | x := req.do()! |
| 350 | |
| 351 | encoding := x.header.get(.content_encoding) or { '' } |
| 352 | assert encoding == '', 'Expected no encoding when neither gzip nor zstd is in Accept-Encoding, got: ${encoding}' |
| 353 | assert x.body == 'content response, 2' |
| 354 | } |
| 355 | |
| 356 | // Regression test: when global after-middleware sends response, route after-middleware should be skipped |
| 357 | // This tests the fix for "a response cannot be sent twice over one connection" error |
| 358 | fn test_double_after_middleware_no_error() { |
| 359 | x := http.get('${localserver}/double_after')! |
| 360 | // Global after-middleware response should be received |
| 361 | assert x.body == 'global after response' |
| 362 | // Route after-middleware should NOT have run (header should be absent) |
| 363 | route_after_ran := x.header.get_custom('X-ROUTE-AFTER-RAN') or { '' } |
| 364 | assert route_after_ran == '', 'Route after-middleware should be skipped when global already sent response' |
| 365 | } |
| 366 | |