Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

@@ -0,0 +1,58 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import net.http
import time
import veb
const catchall_route_order_port = 13062
struct CatchallRouteOrderContext {
veb.Context
}
struct CatchallRouteOrderApp {
mut:
server &veb.Server = unsafe { nil }
started chan bool
}
pub fn (mut app CatchallRouteOrderApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app CatchallRouteOrderApp) before_accept_loop() {
app.started <- true
}
@['/:path...']
fn (mut app CatchallRouteOrderApp) any(mut ctx CatchallRouteOrderContext, path string) veb.Result {
ctx.res.set_status(.not_found)
return ctx.text('fallback ${path}')
}
@['/api/user/:id']
fn (mut app CatchallRouteOrderApp) get_user(mut ctx CatchallRouteOrderContext, id string) veb.Result {
return ctx.text('user ${id}')
}
fn test_variadic_routes_do_not_override_more_specific_handlers() {
mut app := &CatchallRouteOrderApp{}
spawn veb.run_at[CatchallRouteOrderApp, CatchallRouteOrderContext](mut app,
port: catchall_route_order_port
show_startup_message: false
timeout_in_seconds: 1
family: .ip
)
_ := <-app.started
app.server.wait_till_running(max_retries: 1000, retry_period_ms: 10)!
defer {
app.server.shutdown(timeout: 5 * time.second) or {}
}
user := http.get('http://127.0.0.1:${catchall_route_order_port}/api/user/42')!
assert user.status() == .ok
assert user.body == 'user 42'
missing := http.get('http://127.0.0.1:${catchall_route_order_port}/missing/path')!
assert missing.status() == .not_found
assert missing.body == 'fallback /missing/path'
}
@@ -0,0 +1,123 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import crypto.sha256
import io
import net
import time
import veb
const chunked_multipart_port = 13005
const chunked_multipart_boundary = '----VChunkedMultipartBoundary'
const chunked_multipart_chunk_size = 4096
const chunked_multipart_localserver = '127.0.0.1:${chunked_multipart_port}'
pub struct ChunkedMultipartContext {
veb.Context
}
pub struct ChunkedMultipartApp {
pub mut:
started chan bool
}
pub fn (mut app ChunkedMultipartApp) before_accept_loop() {
app.started <- true
}
@['/upload'; post]
pub fn (mut app ChunkedMultipartApp) upload(mut ctx ChunkedMultipartContext) veb.Result {
if 'file' !in ctx.files || ctx.files['file'].len == 0 {
ctx.res.set_status(.bad_request)
return ctx.text('missing file')
}
file := ctx.files['file'][0]
return ctx.text(sha256.sum256(file.data.bytes()).hex())
}
fn testsuite_begin() {
mut app := &ChunkedMultipartApp{}
spawn veb.run_at[ChunkedMultipartApp, ChunkedMultipartContext](mut app,
port: chunked_multipart_port
family: .ip
timeout_in_seconds: 10
)
_ := <-app.started
}
fn test_large_chunked_multipart_form_upload() {
payload := make_chunked_multipart_payload(2 * 1024 * 1024)
expected_hash := sha256.sum256(payload).hex()
response := send_chunked_multipart_request(payload) or {
assert false, 'failed to send chunked multipart request: ${err}'
return
}
assert response.starts_with('HTTP/1.1 200 OK')
assert response.all_after('\r\n\r\n') == expected_hash
}
fn make_chunked_multipart_payload(size int) []u8 {
mut payload := []u8{len: size}
for i in 0 .. size {
payload[i] = u8(i % 256)
}
return payload
}
fn send_chunked_multipart_request(payload []u8) !string {
mut client := net.dial_tcp(chunked_multipart_localserver)!
defer {
client.close() or {}
}
client.set_read_timeout(10 * time.second)
client.set_write_timeout(10 * time.second)
headers := 'POST /upload HTTP/1.1\r\nHost: ${chunked_multipart_localserver}\r\nTransfer-Encoding: chunked\r\nContent-Type: multipart/form-data; boundary=${chunked_multipart_boundary}\r\nConnection: close\r\n\r\n'
client.write_string(headers)!
body := build_multipart_body(payload)
write_chunked_body(mut client, body)!
return read_response(mut client)
}
fn build_multipart_body(payload []u8) []u8 {
mut body := []u8{}
body << '--${chunked_multipart_boundary}\r\n'.bytes()
body << 'Content-Disposition: form-data; name="file"; filename="payload.bin"\r\n'.bytes()
body << 'Content-Type: application/octet-stream\r\n\r\n'.bytes()
body << payload
body << '\r\n--${chunked_multipart_boundary}--\r\n'.bytes()
return body
}
fn write_chunked_body(mut client net.TcpConn, body []u8) ! {
for start := 0; start < body.len; start += chunked_multipart_chunk_size {
end := if start + chunked_multipart_chunk_size < body.len {
start + chunked_multipart_chunk_size
} else {
body.len
}
chunk := body[start..end]
client.write_string('${chunk.len:x}\r\n')!
client.write(chunk)!
client.write_string('\r\n')!
}
client.write_string('0\r\n\r\n')!
}
fn read_response(mut client net.TcpConn) !string {
mut response := []u8{}
mut buf := []u8{len: 4096}
for {
n := client.read(mut buf) or {
if err is io.Eof {
break
}
return err
}
if n == 0 {
break
}
response << buf[..n]
}
return response.bytestr()
}
@@ -0,0 +1,13 @@
import net.http
import veb
fn test_set_header_content_type_preserves_charset_parameter() {
mut ctx := veb.Context{
res: http.Response{}
}
ctx.set_header(.content_type, 'text/html; charset=utf-8')
ctx.text('Hello, World!')
assert ctx.res.header.get(.content_type)? == 'text/html; charset=utf-8'
}
+132
View File
@@ -0,0 +1,132 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import time
import os
import net.http
const port = 13006
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct Context {
veb.Context
}
pub struct App {
veb.Controller
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('from app')
}
@['/conflict/test']
pub fn (app &App) conflicting(mut ctx Context) veb.Result {
return ctx.text('from conflicting')
}
pub struct Other {
veb.Controller
}
pub fn (app &Other) index(mut ctx Context) veb.Result {
return ctx.text('from other')
}
pub struct HiddenByOther {}
pub fn (app &HiddenByOther) index(mut ctx Context) veb.Result {
return ctx.text('from hidden')
}
pub struct SubController {}
pub fn (app &SubController) index(mut ctx Context) veb.Result {
return ctx.text('from sub')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut sub := &SubController{}
mut other := &Other{}
other.register_controller[SubController, Context]('/sub', mut sub)!
mut hidden := &HiddenByOther{}
mut app := &App{}
app.register_controller[Other, Context]('/other', mut other)!
// controllers should be sorted, so this controller should be accessible
// even though it is declared last
app.register_controller[HiddenByOther, Context]('/other/hide', mut hidden)!
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_app_home() {
x := http.get(localserver)!
assert x.body == 'from app'
}
fn test_other() {
x := http.get('${localserver}/other')!
assert x.body == 'from other'
}
fn test_sub_controller() {
x := http.get('${localserver}/other/sub')!
assert x.body == 'from sub'
}
fn test_hidden_route() {
x := http.get('${localserver}/other/hide')!
assert x.body == 'from hidden'
}
fn test_conflicting_controllers() {
mut other := &Other{}
mut app := &App{}
app.register_controller[Other, Context]('/other', mut other) or {
assert true == false, 'this should not fail'
}
app.register_controller[Other, Context]('/other', mut other) or {
assert true == false, 'this should not fail'
}
veb.run_at[App, Context](mut app, port: port) or {
assert err.msg() == 'conflicting paths: duplicate controller handling the route "/other"'
return
}
assert true == false, 'the previous call should have failed!'
}
fn test_conflicting_controller_routes() {
mut other := &Other{}
mut app := &App{}
app.register_controller[Other, Context]('/conflict', mut other) or {
assert true == false, 'this should not fail'
}
veb.run_at[App, Context](mut app, port: port) or {
assert err.msg() == 'conflicting paths: method "conflicting" with route "/conflict/test" should be handled by the Controller of path "/conflict"'
return
}
assert true == false, 'the previous call should have failed!'
}
+108
View File
@@ -0,0 +1,108 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13012
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
const cors_options = veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [.get, .head]
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut context Context) veb.Result {
return context.text('index')
}
@[post]
pub fn (app &App) post(mut context Context) veb.Result {
return context.text('post')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](cors_options))
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_valid_cors() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'index'
}
fn test_preflight() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .options
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'ok'
assert x.header.get(.access_control_allow_origin)? == allowed_origin
if _ := x.header.get(.access_control_allow_credentials) {
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
}
assert x.header.get(.access_control_allow_methods)? == 'GET, HEAD'
}
fn test_invalid_origin() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: 'https://google.com'
})
})!
assert x.status() == .forbidden
}
fn test_invalid_method() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/post'
method: .post
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .method_not_allowed
}
@@ -0,0 +1,160 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import time
import os
const base_port = 13013
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
fn get_port_and_url(test_number int) (int, string) {
p := base_port + test_number
return p, 'http://127.0.0.1:${p}'
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('index')
}
fn setup(port int, o veb.CorsOptions) ! {
os.chdir(os.dir(@FILE))!
go fn () {
time.sleep(exit_after)
assert false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](o))
go veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_no_user_provided_allowed_headers() {
port, localserver := get_port_and_url(1)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert false, 'Header should not be set'
}
}
fn test_user_provided_allowed_header() {
port, localserver := get_port_and_url(2)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_headers: ['content-type']
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert header == 'content-type'
} else {
assert false, 'Header not set'
}
}
fn test_user_provided_allowed_header_wildcard() {
port, localserver := get_port_and_url(3)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_headers: ['*']
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert header == '*'
} else {
assert false, 'Header not set'
}
}
fn test_request_has_access_control_request_headers() {
port, localserver := get_port_and_url(4)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
http.CommonHeader.access_control_request_headers: 'any-value'
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(http.CommonHeader.access_control_allow_headers) {
assert header == veb.cors_safelisted_response_headers
} else {
assert false, 'Header not set'
}
}
fn test_allow_credentials_non_preflight() {
port, localserver := get_port_and_url(5)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [http.Method.get]
allow_credentials: true
})!
x := http.fetch(http.FetchConfig{
url: localserver
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(http.CommonHeader.access_control_allow_credentials) {
assert header == 'true'
} else {
assert false, 'Header not set'
}
}
+108
View File
@@ -0,0 +1,108 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13012
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
const cors_options = veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [.get, .head]
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('index')
}
@[post]
pub fn (app &App) post(mut ctx Context) veb.Result {
return ctx.text('post')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](cors_options))
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_valid_cors() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'index'
}
fn test_preflight() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .options
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'ok'
assert x.header.get(.access_control_allow_origin)? == allowed_origin
if _ := x.header.get(.access_control_allow_credentials) {
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
}
assert x.header.get(.access_control_allow_methods)? == 'GET, HEAD'
}
fn test_invalid_origin() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: 'https://google.com'
})
})!
assert x.status() == .forbidden
}
fn test_invalid_method() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/post'
method: .post
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .method_not_allowed
}
@@ -0,0 +1,155 @@
// vtest build: !windows && !tinyc // fasthttp.Server.run is not implemented on windows; the test hangs intermittently when built with tcc on linux CI
import net
import net.http
import time
import veb
const graceful_shutdown_host = '127.0.0.1'
const graceful_shutdown_wait_timeout = 10 * time.second
struct GracefulShutdownContext {
veb.Context
}
struct GracefulShutdownApp {
mut:
server &veb.Server = unsafe { nil }
slow_started chan bool = chan bool{cap: 1}
}
pub fn (mut app GracefulShutdownApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app GracefulShutdownApp) index(mut ctx GracefulShutdownContext) veb.Result {
return ctx.text('ready')
}
@['/slow']
pub fn (mut app GracefulShutdownApp) slow(mut ctx GracefulShutdownContext) veb.Result {
app.slow_started <- true
time.sleep(300 * time.millisecond)
return ctx.text('slow done')
}
fn run_graceful_shutdown_app(mut app GracefulShutdownApp, port int) {
veb.run_at[GracefulShutdownApp, GracefulShutdownContext](mut app,
host: graceful_shutdown_host
port: port
family: .ip
show_startup_message: false
) or { panic(err) }
}
fn send_slow_request(port int, responses chan http.Response) {
response := http.get('http://${graceful_shutdown_host}:${port}/slow') or { panic(err) }
responses <- response
}
fn shutdown_graceful_server(server &veb.Server, done chan string) {
server.shutdown(timeout: 5 * time.second) or {
done <- err.msg()
return
}
done <- ''
}
fn wait_for_slow_request(started chan bool) ! {
select {
_ := <-started {
return
}
graceful_shutdown_wait_timeout {
return error('slow request did not start')
}
}
}
fn wait_for_slow_response(responses chan http.Response) !http.Response {
select {
response := <-responses {
return response
}
graceful_shutdown_wait_timeout {
return error('slow request did not finish')
}
}
return error('slow request did not finish')
}
fn wait_for_shutdown(done chan string) ! {
select {
shutdown_error := <-done {
if shutdown_error != '' {
return error(shutdown_error)
}
return
}
graceful_shutdown_wait_timeout {
return error('server shutdown did not finish')
}
}
}
fn wait_for_server(port int) ! {
url := 'http://${graceful_shutdown_host}:${port}/'
for _ in 0 .. 100 {
response := http.get(url) or {
time.sleep(20 * time.millisecond)
continue
}
if response.status() == .ok && response.body == 'ready' {
return
}
time.sleep(20 * time.millisecond)
}
return error('server did not start listening in time')
}
fn test_veb_graceful_shutdown_waits_for_in_flight_requests() {
mut listener := net.listen_tcp(.ip, '${graceful_shutdown_host}:0') or { panic(err) }
port := listener.addr()!.port()!
listener.close() or {}
mut app := &GracefulShutdownApp{}
server_thread := spawn run_graceful_shutdown_app(mut app, int(port))
wait_for_server(int(port)) or {
assert false, err.msg()
return
}
slow_responses := chan http.Response{cap: 1}
spawn send_slow_request(int(port), slow_responses)
wait_for_slow_request(app.slow_started) or {
assert false, err.msg()
return
}
mut server := app.server
if server == unsafe { nil } {
assert false, 'veb server was not initialized'
return
}
shutdown_done := chan string{cap: 1}
spawn shutdown_graceful_server(server, shutdown_done)
slow_response := wait_for_slow_response(slow_responses) or {
assert false, err.msg()
return
}
assert slow_response.status() == .ok
assert slow_response.body == 'slow done'
wait_for_shutdown(shutdown_done) or {
assert false, err.msg()
return
}
server_thread.wait()
http.get('http://${graceful_shutdown_host}:${port}/') or {
assert true
return
}
assert false, 'server still accepts new requests after shutdown'
}
@@ -0,0 +1,96 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import log
import veb
import time
import net
import x.json2
import net.http
const port = 31228
pub struct Context {
veb.Context
}
pub struct App {
pub mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
@['/data/:filename'; post]
fn (mut app App) data(mut ctx Context, filename string) veb.Result {
content_type := ctx.get_header(http.CommonHeader.content_type) or { return ctx.no_content() }
f := http.FileData{
filename: filename
content_type: content_type
data: ctx.req.data
}
log.info('Received ${filename} with content_type ${content_type} and length ${f.data.len}')
return ctx.json(f)
}
const svg_image_content = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -308 316 316" width="316" height="316"><g fill-opacity="0" stroke="#000" xmlns="http://www.w3.org/2000/svg"><path xmlns="http://www.w3.org/2000/svg" d="M0 0 l -1.8369701987210297e-14 -100 m -1.8369701987210297e-14 -100 l -1.8369701987210297e-14 -100 l 100 -2.4492935982947064e-14 m 100 -2.4492935982947064e-14 l 100 -2.4492935982947064e-14 l 3.061616997868383e-14 100 m 3.061616997868383e-14 100 l 3.061616997868383e-14 100 l -100 3.6739403974420595e-14 m -100 3.6739403974420595e-14 l -100 3.6739403974420595e-14" stroke="#000000" stroke-width="5"></path></g></svg>'
fn test_veb_app_start() {
log.info('starting watchdog ...')
spawn fn () {
log.info('watchdog running')
time.sleep(10 * time.second)
log.info('exiting...')
exit(0)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port)
_ := <-app.started
log.info('app started')
}
fn test_make_request() {
mut client := net.dial_tcp('127.0.0.1:${port}')!
defer { client.close() or {} }
client.write_string('POST /data/Seeker.svg HTTP/1.1\r
Host: localhost:8090\r
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0\r
Accept: */*\r
Accept-Language: en-US,en;q=0.5\r
Accept-Encoding: gzip, deflate, br, zstd\r
Content-Type: image/svg+xml\r
Content-Length: 618\r
Origin: null\r
Connection: close\r
Sec-Fetch-Dest: empty\r
Sec-Fetch-Mode: cors\r
Sec-Fetch-Site: cross-site\r
Priority: u=4\r
\r
')! // "
time.sleep(25 * time.millisecond)
client.write_string(svg_image_content)!
mut res := []u8{}
mut buf := []u8{len: 512}
for {
read_len := client.read(mut buf) or { break }
if read_len == 0 {
break
}
res << buf[0..read_len]
}
response := res.bytestr()
assert response.starts_with('HTTP/1.1 200 OK')
assert response.contains('Content-Length: 706')
assert response.contains('Content-Type: application/json')
payload := response.all_after('\r\n\r\n')
r := json2.decode[http.FileData](payload)!
dump(r.filename)
dump(r.content_type)
assert r.filename == 'Seeker.svg'
assert r.content_type == 'image/svg+xml'
assert r.data.starts_with('<svg xmlns=')
assert r.data.ends_with('</svg>')
assert r.data == svg_image_content
}
@@ -0,0 +1,26 @@
import veb
pub struct App {}
struct Context {
veb.Context
}
// Test that methods with route attributes but wrong return type are caught
fn test_invalid_return_type_is_detected() {
mut app := &App{}
// This should fail because get_task has route attributes but returns !veb.Result
veb.generate_routes[App, Context](app) or {
assert err.msg().contains('invalid return type'), 'Expected error about invalid return type, got: ${err.msg()}'
assert err.msg().contains('get_task'), 'Expected error to mention method name, got: ${err.msg()}'
return
}
assert false, 'Expected generate_routes to return an error for invalid return type'
}
@['/test'; get]
pub fn (app &App) get_task(mut ctx Context) !veb.Result {
return ctx.text('Hello')
}
+143
View File
@@ -0,0 +1,143 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import time
import os
const port = 13002
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const tmp_file = os.join_path(os.vtmp_dir(), 'veb_large_payload.txt')
pub struct App {
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
@[post]
pub fn (mut app App) post_request(mut ctx Context) veb.Result {
return ctx.text(ctx.req.data)
}
pub fn (app &App) file(mut ctx Context) veb.Result {
return ctx.file(tmp_file)
}
pub struct Context {
veb.Context
}
fn testsuite_begin() {
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_large_request_body() {
// string of a's of 8.96mb send over the connection
// veb reads a maximum of 4096KB per read cycle
// this test tests if veb is able to do multiple of these
// cycles and updates the response body each cycle
mut buf := []u8{len: veb.max_read * 10, init: `a`}
str := buf.bytestr()
mut x := http.post('${localserver}/post_request', str)!
assert x.body.len == veb.max_read * 10
}
fn test_large_request_header() {
// same test as test_large_request_body, but then with a large header,
// which is parsed separately
mut buf := []u8{len: veb.max_read * 2, init: `a`}
str := buf.bytestr()
// make 1 header longer than vebs max read limit
mut x := http.fetch(http.FetchConfig{
url: localserver
header: http.new_custom_header_from_map({
'X-Overflow-Header': str
})!
})!
assert x.status() == .request_entity_too_large
}
fn test_bigger_content_length() {
data := '123456789'
mut x := http.fetch(http.FetchConfig{
method: .post
url: '${localserver}/post_request'
header: http.new_header_from_map({
.content_length: '10'
})
data: data
})!
// Content-length is larger than the data sent, so the request should timeout
assert x.status() == .request_timeout
}
fn test_smaller_content_length() {
data := '123456789'
mut x := http.fetch(http.FetchConfig{
method: .post
url: '${localserver}/post_request'
header: http.new_header_from_map({
.content_length: '5'
})
data: data
})!
assert x.status() == .bad_request
assert x.body == 'Mismatch of body length and Content-Length header'
}
fn test_sendfile() {
mut buf := []u8{len: veb.max_write * 10, init: `a`}
os.write_file(tmp_file, buf.bytestr())!
x := http.get('${localserver}/file')!
assert x.body.len == veb.max_write * 10
}
// Regression test for https://github.com/vlang/v/issues/27080:
// sendfile() returned EAGAIN once the kernel send buffer filled and
// the server gave up after 3 tight-loop retries. With proper poll()
// based waiting, files larger than the socket send buffer must still
// transfer in full.
fn test_sendfile_large_payload() {
// 8 MiB is well above the typical SO_SNDBUF (~200 KiB on Linux),
// so a single sendfile() call cannot drain the whole file.
size := 8 * 1024 * 1024
mut buf := []u8{len: size, init: `b`}
os.write_file(tmp_file, buf.bytestr())!
x := http.get('${localserver}/file')!
assert x.body.len == size
}
fn testsuite_end() {
os.rm(tmp_file)!
}
+121
View File
@@ -0,0 +1,121 @@
// vtest flaky: true
// vtest retry: 3
// vtest build: !windows && !docker-ubuntu-musl && !sanitize-memory-clang && !sanitize-address-clang-without-gc
import os
import time
import net.http
const sport = 13085
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 30000
const vexe = os.getenv('VEXE')
const vroot = os.dir(vexe)
const serverexe = os.join_path(os.cache_dir(), 'veb_memory_test_server.exe')
fn testsuite_begin() {
os.chdir(vroot) or {}
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn testsuite_end() {
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn test_server_compiles() {
did_compile :=
os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/veb/tests/memory_leak_test_server.v')
assert did_compile == 0
assert os.exists(serverexe)
}
fn test_server_runs_in_background() {
spawn os.system('${os.quoted_path(serverexe)} ${sport} ${exit_after_time}')
for _ in 0 .. 50 {
resp := http.get('http://${localserver}/heap') or {
time.sleep(100 * time.millisecond)
continue
}
if resp.status_code == 200 {
return
}
time.sleep(100 * time.millisecond)
}
assert false, 'Timed out waiting for background server to start'
}
fn test_large_responses_work_correctly() {
// Make requests with large response bodies and verify they work
request_count := 20
mut successful := 0
for _ in 0 .. request_count {
resp := http.get('http://${localserver}/large') or { continue }
if resp.status_code == 200 && resp.body.len > 50000 {
successful += 1
}
}
// Ensure most requests succeeded (allows for some network variability)
assert successful >= request_count - 2, 'Only ${successful}/${request_count} requests succeeded'
}
fn test_heap_usage_endpoint_works() {
resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get heap usage: ${err}'
return
}
heap := resp.body.i64()
// Just verify we get a reasonable value (more than 1MB, less than 1GB)
assert heap > 1024 * 1024, 'Heap usage ${heap} seems too low'
assert heap < 1024 * 1024 * 1024, 'Heap usage ${heap} seems too high'
}
fn test_gc_collect_endpoint_works() {
resp := http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC: ${err}'
return
}
assert resp.status_code == 200
}
fn test_multipart_upload_does_not_expand_heap_excessively() {
http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC before measuring heap: ${err}'
return
}
before_resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get baseline heap usage: ${err}'
return
}
before_heap := before_resp.body.i64()
payload_size := 1024 * 1024
mut files := []http.FileData{}
files << http.FileData{
filename: 'payload.bin'
content_type: 'application/octet-stream'
data: 'X'.repeat(payload_size)
}
resp := http.post_multipart_form('http://${localserver}/upload', http.PostMultipartFormConfig{
files: {
'file': files
}
}) or {
assert false, 'Failed to post multipart upload: ${err}'
return
}
assert resp.status_code == 200
assert resp.body == payload_size.str()
http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC after upload: ${err}'
return
}
after_resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get post-upload heap usage: ${err}'
return
}
after_heap := after_resp.body.i64()
heap_growth := if after_heap > before_heap { after_heap - before_heap } else { i64(0) }
assert heap_growth < 8 * 1024 * 1024, 'Multipart upload grew heap by ${heap_growth} bytes'
}
@@ -0,0 +1,66 @@
module main
import os
import veb
import time
struct Context {
veb.Context
}
struct App {}
fn exit_after_timeout(timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
exit(0)
}
fn main() {
if os.args.len != 3 {
panic('Usage: memory_leak_test_server.exe PORT TIMEOUT_IN_MILLISECONDS')
}
http_port := os.args[1].int()
assert http_port > 0
timeout := os.args[2].int()
assert timeout > 0
spawn exit_after_timeout(timeout)
mut app := &App{}
veb.run_at[App, Context](mut app,
host: '127.0.0.1'
port: http_port
family: .ip
timeout_in_seconds: 10
)!
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('OK')
}
@['/heap']
pub fn (mut app App) heap(mut ctx Context) veb.Result {
usage := gc_heap_usage()
return ctx.text('${usage.heap_size}')
}
@['/gc']
pub fn (mut app App) gc_collect(mut ctx Context) veb.Result {
gc_collect()
return ctx.text('GC collected')
}
@['/large']
pub fn (mut app App) large(mut ctx Context) veb.Result {
data := 'X'.repeat(100 * 1024)
return ctx.text(data)
}
@['/upload'; post]
pub fn (mut app App) upload(mut ctx Context) veb.Result {
if 'file' !in ctx.files || ctx.files['file'].len == 0 {
ctx.res.set_status(.bad_request)
return ctx.text('no file')
}
return ctx.text(ctx.files['file'][0].data.len.str())
}
+365
View File
@@ -0,0 +1,365 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
import compress.gzip
import compress.zstd
const port = 13001
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct Context {
veb.Context
pub mut:
counter int
bound_middleware_tag string
}
@[heap]
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
bound_middleware_hits int
bound_middleware_value string
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('from index, ${ctx.counter}')
}
@['/bar/bar']
pub fn (app &App) bar(mut ctx Context) veb.Result {
return ctx.text('from bar, ${ctx.counter}')
}
pub fn (app &App) unreachable(mut ctx Context) veb.Result {
return ctx.text('should never be reachable!')
}
@['/nested/route/method']
pub fn (app &App) nested(mut ctx Context) veb.Result {
return ctx.text('from nested, ${ctx.counter}')
}
pub fn (app &App) after(mut ctx Context) veb.Result {
return ctx.text('from after, ${ctx.counter}')
}
@['/admin/auth'; get]
pub fn (app &App) admin_auth_get(mut ctx Context) veb.Result {
return ctx.text('protected get route')
}
@['/admin/auth'; post]
pub fn (app &App) admin_auth_post(mut ctx Context) veb.Result {
return ctx.text('public post route')
}
@['/gzip']
pub fn (app &App) gzip_test(mut ctx Context) veb.Result {
return ctx.text('gzip response, ${ctx.counter}')
}
@['/decode_gzip'; post]
pub fn (app &App) decode_gzip_test(mut ctx Context) veb.Result {
return ctx.text('received: ${ctx.req.data}')
}
@['/zstd']
pub fn (app &App) zstd_test(mut ctx Context) veb.Result {
return ctx.text('zstd response, ${ctx.counter}')
}
@['/decode_zstd'; post]
pub fn (app &App) decode_zstd_test(mut ctx Context) veb.Result {
return ctx.text('received: ${ctx.req.data}')
}
@['/content']
pub fn (app &App) content_test(mut ctx Context) veb.Result {
return ctx.text('content response, ${ctx.counter}')
}
// Test route for double-send regression test
@['/double_after']
pub fn (app &App) double_after(mut ctx Context) veb.Result {
return ctx.text('handler response')
}
// bound verifies that route middleware can read app state before the handler runs.
@['/bound']
pub fn (app &App) bound(mut ctx Context) veb.Result {
return ctx.text('${app.bound_middleware_value}, ${app.bound_middleware_hits}, ${ctx.bound_middleware_tag}, ${ctx.counter}')
}
pub fn (app &App) app_middleware(mut ctx Context) bool {
ctx.counter++
return true
}
pub fn (app &App) before_request(mut ctx Context) bool {
ctx.res.header.add_custom('X-BEFORE-REQUEST-MIDDLEWARE', 'true') or { panic(err) }
return true
}
// route_bound_middleware reads and mutates the bound app to exercise stateful middleware.
pub fn (mut app App) route_bound_middleware(mut ctx Context) bool {
app.bound_middleware_hits++
ctx.bound_middleware_tag = app.bound_middleware_value
ctx.counter += 10
return true
}
fn middleware_handler(mut ctx Context) bool {
ctx.counter++
return true
}
fn middleware_unreachable(mut ctx Context) bool {
ctx.text('unreachable, ${ctx.counter}')
return false
}
fn after_middleware(mut ctx Context) bool {
ctx.counter++
ctx.res.header.add_custom('X-AFTER', ctx.counter.str()) or { panic('bad') }
return true
}
fn auth_required_middleware(mut ctx Context) bool {
ctx.res.set_status(.unauthorized)
ctx.text('auth required')
return false
}
// Global after-middleware that sends a response (for double-send regression test)
// Only sends for /double_after route to avoid breaking other tests
fn global_after_sends_response(mut ctx Context) bool {
if ctx.req.url.starts_with('/double_after') {
ctx.text('global after response')
}
return true
}
// Route after-middleware that also tries to send (should be skipped if global already sent)
fn route_after_sends_response(mut ctx Context) bool {
// Add header to prove this middleware ran (for regression test)
ctx.res.header.add_custom('X-ROUTE-AFTER-RAN', 'true') or {}
ctx.text('route after response - should not appear')
return true
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut app := &App{
bound_middleware_value: 'from app middleware'
}
// even though `route_use` is called first, global middleware is still executed first
app.Middleware.route_use('/unreachable', handler: middleware_unreachable)
// global middleware
app.Middleware.use(handler: middleware_handler)
app.Middleware.use(handler: app.app_middleware)
app.Middleware.use(handler: app.before_request)
// should match only one slash
app.Middleware.route_use('/bar/:foo', handler: middleware_handler)
// should match multiple slashes
app.Middleware.route_use('/nested/:path...', handler: middleware_handler)
app.Middleware.route_use('/bound', handler: app.route_bound_middleware)
app.Middleware.route_use('/after', handler: after_middleware, after: true)
app.Middleware.route_use('/admin/auth',
handler: auth_required_middleware
methods: [
.get,
]
)
// Gzip middleware tests
app.Middleware.use(veb.decode_gzip[Context]())
app.Middleware.route_use('/gzip', veb.encode_gzip[Context]())
// Zstd middleware tests
app.Middleware.use(veb.decode_zstd[Context]())
app.Middleware.route_use('/zstd', veb.encode_zstd[Context]())
// Auto content encoding middleware (zstd/gzip based on Accept-Encoding)
app.Middleware.route_use('/content', veb.encode_auto[Context]())
// Regression test: global after-middleware sends response, route after-middleware should be skipped
app.Middleware.use(handler: global_after_sends_response, after: true)
app.Middleware.route_use('/double_after', handler: route_after_sends_response, after: true)
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_index() {
x := http.get(localserver)!
assert x.body == 'from index, 2'
}
fn test_bound_before_request_named_middleware_does_not_timeout() {
x := http.fetch(http.FetchConfig{
url: localserver
read_timeout: 1500 * time.millisecond
write_timeout: 1500 * time.millisecond
})!
assert x.body == 'from index, 2'
assert x.header.get_custom('X-BEFORE-REQUEST-MIDDLEWARE')? == 'true'
}
fn test_unreachable_order() {
x := http.get('${localserver}/unreachable')!
assert x.body == 'unreachable, 2'
}
fn test_dynamic_route() {
x := http.get('${localserver}/bar/bar')!
assert x.body == 'from bar, 3'
}
fn test_nested() {
x := http.get('${localserver}/nested/route/method')!
assert x.body == 'from nested, 3'
}
fn test_route_middleware_bound_method_can_access_app_state() {
x := http.get('${localserver}/bound')!
assert x.body == 'from app middleware, 1, from app middleware, 12'
}
fn test_route_middleware_method_filter_get() {
x := http.get('${localserver}/admin/auth')!
assert x.status() == .unauthorized
assert x.body == 'auth required'
}
fn test_route_middleware_method_filter_post() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/admin/auth'
method: .post
})!
assert x.status() == .ok
assert x.body == 'public post route'
}
fn test_after_middleware() {
x := http.get('${localserver}/after')!
assert x.body == 'from after, 2'
custom_header := x.header.get_custom('X-AFTER') or { panic('should be set!') }
assert custom_header == '3'
}
// Verifies that encode_gzip compresses responses
fn test_encode_gzip_middleware() {
x := http.get('${localserver}/gzip')!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'gzip', 'Expected gzip encoding, got: ${encoding}'
// HTTP client auto-decompresses gzip content
assert x.body == 'gzip response, 2'
}
// Verifies that decode_gzip middleware decompresses request bodies
fn test_decode_gzip_middleware() {
original_text := 'Hello from gzip compressed body!'
compressed := gzip.compress(original_text.bytes())!
mut req := http.new_request(.post, '${localserver}/decode_gzip', compressed.bytestr())
req.header.add(.content_encoding, 'gzip')
x := req.do()!
assert x.body == 'received: ${original_text}'
}
// Verifies that encode_zstd compresses responses
fn test_encode_zstd_middleware() {
x := http.get('${localserver}/zstd')!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'zstd', 'Expected zstd encoding, got: ${encoding}'
decompressed := zstd.decompress(x.body.bytes())!
assert decompressed.bytestr() == 'zstd response, 2'
}
// Verifies that decode_zstd middleware decompresses request bodies
fn test_decode_zstd_middleware() {
original_text := 'Hello from zstd compressed body!'
compressed := zstd.compress(original_text.bytes())!
mut req := http.new_request(.post, '${localserver}/decode_zstd', compressed.bytestr())
req.header.add(.content_encoding, 'zstd')
x := req.do()!
assert x.body == 'received: ${original_text}'
}
// Verifies that encode_auto uses zstd when client supports it
fn test_encode_auto_zstd() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'gzip, zstd, br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'zstd', 'Expected zstd encoding when zstd is in Accept-Encoding, got: ${encoding}'
decompressed := zstd.decompress(x.body.bytes())!
assert decompressed.bytestr() == 'content response, 2'
}
// Verifies that encode_auto falls back to gzip when client doesn't support zstd
fn test_encode_auto_gzip_fallback() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'gzip, br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'gzip', 'Expected gzip encoding when zstd is not in Accept-Encoding, got: ${encoding}'
// HTTP client auto-decompresses gzip content
assert x.body == 'content response, 2'
}
// Verifies that encode_auto sends uncompressed when no encoding is supported
fn test_encode_auto_no_compression() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == '', 'Expected no encoding when neither gzip nor zstd is in Accept-Encoding, got: ${encoding}'
assert x.body == 'content response, 2'
}
// Regression test: when global after-middleware sends response, route after-middleware should be skipped
// This tests the fix for "a response cannot be sent twice over one connection" error
fn test_double_after_middleware_no_error() {
x := http.get('${localserver}/double_after')!
// Global after-middleware response should be received
assert x.body == 'global after response'
// Route after-middleware should NOT have run (header should be absent)
route_after_ran := x.header.get_custom('X-ROUTE-AFTER-RAN') or { '' }
assert route_after_ran == '', 'Route after-middleware should be skipped when global already sent response'
}
@@ -0,0 +1,58 @@
import veb
pub struct NestedEmbeddedContext {
veb.Context
}
pub struct NestedEmbeddedApp {
veb.Middleware[NestedEmbeddedContext]
veb.Controller
veb.StaticHandler
}
struct NestedEmbeddedAliasApp {
NestedEmbeddedApp
}
struct NestedEmbeddedBase {
veb.Middleware[NestedEmbeddedContext]
}
fn test_nested_embedded_controller_registration() {
mut app := &NestedEmbeddedAliasApp{}
app.routes_base()
routes := veb.generate_routes[NestedEmbeddedAliasApp, NestedEmbeddedContext](app) or {
panic(err)
}
assert app.controllers.len == 1
assert app.controllers[0].path == '/base'
assert 'index' in routes
}
fn exercise_nested_embedded_run_path() {
mut app := &NestedEmbeddedAliasApp{}
app.routes_base()
veb.run_at[NestedEmbeddedAliasApp, NestedEmbeddedContext](mut app,
port: 0
show_startup_message: false
family: .ip
timeout_in_seconds: 1
) or { panic(err) }
}
fn (mut app NestedEmbeddedAliasApp) routes_base() {
mut base_app := &NestedEmbeddedBase{}
app.register_controller[NestedEmbeddedBase, NestedEmbeddedContext]('/base', mut base_app) or {
panic(err)
}
}
@['/'; get]
fn (mut app NestedEmbeddedAliasApp) index(mut ctx NestedEmbeddedContext) veb.Result {
return ctx.text('index success')
}
@['/get'; get]
fn (mut app NestedEmbeddedBase) base(mut ctx NestedEmbeddedContext) veb.Result {
return ctx.text('base success')
}
@@ -0,0 +1,189 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import net
import net.http
import io
import os
import time
import veb
const exit_after = time.second * 10
const port = 13009
const localserver = 'localhost:${port}'
const tcp_r_timeout = 2 * time.second
const tcp_w_timeout = 2 * time.second
const max_retries = 4
const default_request = 'GET / HTTP/1.1
User-Agent: VTESTS
Accept: */*
\r\n'
const response_body = 'intact!'
pub struct Context {
veb.Context
}
pub struct App {
mut:
started chan bool
counter int
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
app.counter++
return ctx.text('${response_body}:${app.counter}')
}
pub fn (mut app App) reset(mut ctx Context) veb.Result {
app.counter = 0
return ctx.ok('')
}
pub fn (mut app App) reusable(mut ctx Context) veb.Result {
ctx.takeover_conn_reusable()
body := 'manual:${app.counter}'
ctx.conn.write_string('HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n${body.len:x}\r\n${body}\r\n0\r\n\r\n') or {}
return veb.no_result()
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 5)
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_conn_remains_intact() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string(default_request)!
mut read := io.read_all(reader: conn)!
mut response := read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:1') == true, 'read response: ${response}'
// send request again over the same connection
conn.write_string(default_request)!
read = io.read_all(reader: conn)!
response = read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:2') == true, 'read response: ${response}'
conn.close() or {}
}
fn test_conn_remains_intact_after_reusable_takeover() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string('GET /reusable HTTP/1.1\r\nUser-Agent: VTESTS\r\nAccept: */*\r\n\r\n')!
mut response := read_until_contains(mut conn, '\r\n0\r\n\r\n')!
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.contains('manual:0') == true, 'read response: ${response}'
assert response.contains('\r\n0\r\n\r\n') == true, 'read response: ${response}'
// send request again over the same connection
conn.write_string(default_request)!
read := io.read_all(reader: conn)!
response = read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:1') == true, 'read response: ${response}'
conn.close() or {}
}
fn test_reusable_takeover_honors_connection_close() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string('GET /reusable HTTP/1.1\r\nUser-Agent: VTESTS\r\nAccept: */*\r\nConnection: close\r\n\r\n')!
response := read_until_contains(mut conn, '\r\n0\r\n\r\n')!
assert response.contains('manual:0') == true, 'read response: ${response}'
assert response.contains('\r\n0\r\n\r\n') == true, 'read response: ${response}'
conn.write_string(default_request) or {}
next := io.read_all(reader: conn) or { []u8{} }
assert next.len == 0
conn.close() or {}
}
fn test_support_http_1() {
http.get('http://${localserver}/reset')!
// HTTP 1.0 always closes the connection after each request, so the client must
// send the Connection: close header. If that header is present the connection
// needs to be closed and a `Connection: close` header needs to be send back
mut x := http.fetch(http.FetchConfig{
url: 'http://${localserver}/'
header: http.new_header_from_map({
.connection: 'close'
})
})!
assert x.status() == .ok
if conn_header := x.header.get(.connection) {
assert conn_header == 'close'
} else {
assert false, '`Connection: close` header should be present!'
}
}
// utility code:
fn simple_tcp_client() !&net.TcpConn {
mut client := &net.TcpConn(unsafe { nil })
mut tries := 0
for tries < max_retries {
tries++
eprintln('> client retries: ${tries}')
client = net.dial_tcp(localserver) or {
eprintln('dial error: ${err.msg()}')
if tries > max_retries {
return err
}
time.sleep(100 * time.millisecond)
continue
}
break
}
if client == unsafe { nil } {
eprintln('could not create a tcp client connection to http://${localserver} after ${max_retries} retries')
exit(1)
}
client.set_read_timeout(tcp_r_timeout)
client.set_write_timeout(tcp_w_timeout)
return client
}
fn read_until_contains(mut conn net.TcpConn, marker string) !string {
mut raw := ''
mut buf := []u8{len: 1024}
for _ in 0 .. 16 {
n := conn.read(mut buf)!
if n <= 0 {
break
}
raw += buf[..n].bytestr()
if raw.contains(marker) {
break
}
}
return raw
}
+46
View File
@@ -0,0 +1,46 @@
// vtest build: !sanitized_job?
import net.http
import net.mbedtls
import os
import veb
const https_port = 13013
pub struct Context {
veb.Context
}
pub struct App {
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('secure')
}
fn test_veb_serves_https_requests() {
cert_path := os.join_path(@VMODROOT, 'examples', 'ssl_server', 'cert', 'server.crt')
key_path := os.join_path(@VMODROOT, 'examples', 'ssl_server', 'cert', 'server.key')
mut app := &App{}
spawn veb.run_at[App, Context](mut app,
host: '127.0.0.1'
port: https_port
family: .ip
timeout_in_seconds: 2
ssl_config: mbedtls.SSLConnectConfig{
cert: cert_path
cert_key: key_path
}
)
_ := <-app.started
res := http.fetch(
url: 'https://127.0.0.1:${https_port}/'
validate: false
)!
assert res.status_code == 200
assert res.body == 'secure'
}
@@ -0,0 +1,629 @@
// vtest build: !docker-ubuntu-musl && !windows // !windows: fasthttp.Server.run not implemented; !docker-ubuntu-musl: readonly-permission assertions can fail there
import veb
import net.http
import os
import time
import compress.gzip
import compress.zstd
const port = 14013
const port_no_auto = 14014 // Port for static_compression_max_size = 0 test
const port_gzip_only = 14015 // Port for enable_static_gzip only test
const port_zstd_only = 14016 // Port for enable_static_zstd only test
const port_filtered_mimes = 14017 // Port for static_compression_mime_types test
const localserver = 'http://127.0.0.1:${port}'
const localserver_no_auto = 'http://127.0.0.1:${port_no_auto}'
const localserver_gzip_only = 'http://127.0.0.1:${port_gzip_only}'
const localserver_zstd_only = 'http://127.0.0.1:${port_zstd_only}'
const localserver_filtered_mimes = 'http://127.0.0.1:${port_filtered_mimes}'
const exit_after = time.second * 30
const test_file_content = 'This is a test file for gzip compression. It contains enough text to make compression worthwhile. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
const filtered_css_content = 'body{margin:0;padding:0;color:#123456;background:#fafafa;}'
const filtered_svg_content = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><rect width="2" height="2" fill="#fff"/></svg>'
pub struct App {
veb.StaticHandler
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
pub struct Context {
veb.Context
}
fn sanitize_cache_path_component(component string) string {
mut sanitized := component.trim_space()
if sanitized == '' {
return 'unknown'
}
for invalid_char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
sanitized = sanitized.replace(invalid_char, '_')
}
return sanitized
}
fn static_cache_root_for_tests() string {
app_dir_name := sanitize_cache_path_component(os.base(os.getwd()))
return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name)
}
fn reset_test_static_cache() {
os.rmdir_all(static_cache_root_for_tests()) or {}
}
fn find_cached_static_file(file_name string, ext string) string {
cache_root := static_cache_root_for_tests()
if !os.exists(cache_root) {
return ''
}
for path in os.walk_ext(cache_root, ext) {
normalized := path.replace('\\', '/')
if normalized.contains('/${file_name}.') {
return path
}
}
return ''
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
reset_test_static_cache()
// Create test directory and files
os.mkdir_all('testdata_compression')!
os.write_file('testdata_compression/test.txt', test_file_content)!
os.write_file('testdata_compression/large.txt', test_file_content.repeat(100))!
// Create readonly directory and file for readonly filesystem test
os.mkdir_all('testdata_compression/readonly')!
os.write_file('testdata_compression/readonly/readonly.txt', 'This is a readonly file test')!
// Create pre-compressed file for manual .gz test
large_content := 'X'.repeat(2000)
os.write_file('testdata_compression/precompressed.txt', large_content)!
compressed_gz := gzip.compress(large_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/precompressed.txt.gz', compressed_gz.bytestr())!
// Create pre-compressed file for manual .zst test
os.write_file('testdata_compression/precompressed_zstd.txt', large_content)!
compressed_zst := zstd.compress(large_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/precompressed_zstd.txt.zst', compressed_zst.bytestr())!
// Create test file for zstd auto-compression
os.write_file('testdata_compression/zstd_test.txt', test_file_content)!
// Create file for testing max_size = 0 (no auto-compression)
os.write_file('testdata_compression/no_auto.txt', 'This file should not be auto-compressed')!
// Create files for gzip-only and zstd-only tests
os.write_file('testdata_compression/gzip_only_test.txt', test_file_content)!
os.write_file('testdata_compression/zstd_only_test.txt', test_file_content)!
os.write_file('testdata_compression/filtered.css', filtered_css_content)!
os.write_file('testdata_compression/filtered.svg', filtered_svg_content)!
filtered_svg_gz := gzip.compress(filtered_svg_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/filtered.svg.gz', filtered_svg_gz.bytestr())!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
run_app_test()
run_no_auto_compression_test()
run_gzip_only_test()
run_zstd_only_test()
run_filtered_mime_test()
}
fn testsuite_end() {
// Clean up test files
os.rmdir_all('testdata_compression') or {}
reset_test_static_cache()
}
fn run_app_test() {
mut app := &App{}
// Enable static compression (zstd/gzip)
app.enable_static_compression = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
// Add compression middleware (gzip for this test app)
app.use(veb.encode_gzip[Context]())
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 25, family: .ip)
_ := <-app.started
}
fn run_no_auto_compression_test() {
mut app := &App{}
// Enable static compression but disable auto-compression (max_size = 0)
app.enable_static_compression = true
app.static_compression_max_size = 0 // Disable auto-compression
app.handle_static('testdata_compression', true) or { panic(err) }
// Add compression middleware (gzip for this test app)
app.use(veb.encode_gzip[Context]())
spawn veb.run_at[App, Context](mut app,
port: port_no_auto
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_gzip_only_test() {
mut app := &App{}
// Enable ONLY gzip compression (not zstd, not auto)
app.enable_static_gzip = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_gzip_only
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_zstd_only_test() {
mut app := &App{}
// Enable ONLY zstd compression (not gzip, not auto)
app.enable_static_zstd = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_zstd_only
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_filtered_mime_test() {
mut app := &App{}
app.enable_static_compression = true
app.static_compression_max_size = 1048576
app.static_compression_mime_types = [veb.mime_types['.css'], veb.mime_types['.js']]
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_filtered_mimes
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn test_gzip_compression_with_accept_encoding() {
// Request with Accept-Encoding: gzip
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_no_compression_without_accept_encoding() {
// Request without Accept-Encoding header
x := http.get('${localserver}/test.txt')!
assert x.status() == .ok
// Should not have content-encoding header when client doesn't accept gzip
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'should not compress without Accept-Encoding: gzip'
}
fn test_gz_file_cache_creation() {
// First request creates a veb-managed .gz cache file.
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
_ := req.do()!
// Cache files should not be created beside the source file.
source_gz_path := 'testdata_compression/test.txt.gz'
assert !os.exists(source_gz_path), '.gz cache file should not be created beside the source file'
gz_path := find_cached_static_file('test.txt', '.gz')
assert gz_path != '', '.gz cache file should be created in veb cache dir'
// Second request should use cached .gz file
y := req.do()!
assert y.status() == .ok
assert y.header.get(.content_encoding)? == 'gzip'
// Verify Content-Length matches .gz file size (tests os.file_size() code path)
gz_file_size := os.file_size(gz_path)
content_length := y.header.get(.content_length)?.u64()
assert content_length == gz_file_size, 'Content-Length should match .gz file size'
}
fn test_large_file_not_auto_compressed() {
// Configure app with very small max size to test threshold
// The large.txt file is ~20KB (200 chars * 100), which exceeds a 1KB threshold
// But we set it to 1MB, so it should still be compressed
// Let's test by checking if it gets compressed
mut req := http.new_request(.get, '${localserver}/large.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// File should be compressed as it's under 1MB threshold
assert x.header.get(.content_encoding)? == 'gzip'
}
fn test_already_compressed_flag() {
// Request a file that will be compressed and cached
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// The file should be compressed only once (in send_file, not by middleware)
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_readonly_filesystem_fallback() {
// Test that compression works when source files are in readonly directories.
// Skip on Windows as readonly permissions work differently (ACL vs chmod)
$if windows {
eprintln('Skipping readonly filesystem test on Windows')
return
}
// Make readonly directory readonly (no write permissions)
readonly_dir := 'testdata_compression/readonly'
readonly_file := '${readonly_dir}/readonly.txt'
os.chmod(readonly_dir, 0o555)! // r-xr-xr-x
mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
// Restore permissions before assertions (for cleanup)
os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x
assert x.status() == .ok
// Should still be compressed.
assert x.header.get(.content_encoding)? == 'gzip'
// Verify that .gz file was NOT created beside the source file.
gz_path := '${readonly_file}.gz'
assert !os.exists(gz_path), '.gz cache file should not be created beside readonly source files'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == 'This is a readonly file test'
}
fn test_readonly_filesystem_fallback_zstd() {
// Test that zstd compression works when source files are in readonly directories.
// Skip on Windows as readonly permissions work differently (ACL vs chmod)
$if windows {
eprintln('Skipping readonly filesystem test on Windows')
return
}
// Make readonly directory readonly (no write permissions)
readonly_dir := 'testdata_compression/readonly'
readonly_file := '${readonly_dir}/readonly.txt'
os.chmod(readonly_dir, 0o555)! // r-xr-xr-x
mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
// Restore permissions before assertions (for cleanup)
os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x
assert x.status() == .ok
// Should still be compressed.
assert x.header.get(.content_encoding)? == 'zstd'
// Verify that .zst file was NOT created beside the source file.
zst_path := '${readonly_file}.zst'
assert !os.exists(zst_path), '.zst cache file should not be created beside readonly source files'
// Verify content is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'response should be valid zstd even on readonly fs: ${err}'
return
}
assert decompressed.bytestr() == 'This is a readonly file test'
}
fn test_precompressed_gz_file_served() {
// Test that manually pre-compressed .gz files are always served
// This validates the manual pre-compression workflow (useful with static_compression_max_size = 0)
// Request the pre-compressed file
mut req := http.new_request(.get, '${localserver}/precompressed.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// Should serve the manually pre-compressed .gz file
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
large_content := 'X'.repeat(2000)
assert x.body == large_content
}
fn test_no_auto_compression_with_max_size_zero() {
// Test that static_compression_max_size = 0 disables auto-compression
// but still serves manually pre-compressed .gz files
// 1. Verify manually pre-compressed .gz files are still served
mut req1 := http.new_request(.get, '${localserver_no_auto}/precompressed.txt', '')
req1.add_header(.accept_encoding, 'gzip')
x := req1.do()!
assert x.status() == .ok
// Should serve the manually pre-compressed .gz file
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
large_content := 'X'.repeat(2000)
assert x.body == large_content
// 2. Verify auto-compression is disabled for files without .gz
mut req2 := http.new_request(.get, '${localserver_no_auto}/no_auto.txt', '')
req2.add_header(.accept_encoding, 'gzip')
y := req2.do()!
assert y.status() == .ok
// Should NOT have content-encoding header (no auto-compression)
_ := y.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert y.body == 'This file should not be auto-compressed'
return
}
assert false, 'should not auto-compress with static_compression_max_size = 0'
// 3. Verify that .gz file was NOT created
gz_path := 'testdata_compression/no_auto.txt.gz'
assert !os.exists(gz_path), '.gz cache file should not be created with max_size = 0'
cache_gz_path := find_cached_static_file('no_auto.txt', '.gz')
assert cache_gz_path == '', '.gz cache file should not be created in veb cache with max_size = 0'
}
// Zstd tests
fn test_zstd_preferred_over_gzip() {
// When client supports both zstd and gzip, zstd should be preferred
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'gzip, zstd, br')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd', 'zstd should be preferred over gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify the body is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zst_file_cache_creation() {
// First request should create a veb-managed .zst cache file.
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'zstd')
_ := req.do()!
// Cache files should not be created beside the source file.
source_zst_path := 'testdata_compression/zstd_test.txt.zst'
assert !os.exists(source_zst_path), '.zst cache file should not be created beside the source file'
zst_path := find_cached_static_file('zstd_test.txt', '.zst')
assert zst_path != '', '.zst cache file should be created in veb cache dir'
// Second request should use cached .zst file
y := req.do()!
assert y.status() == .ok
assert y.header.get(.content_encoding)? == 'zstd'
// Verify Content-Length matches .zst file size
zst_file_size := os.file_size(zst_path)
content_length := y.header.get(.content_length)?.u64()
assert content_length == zst_file_size, 'Content-Length should match .zst file size'
}
fn test_precompressed_zst_file_served() {
// Test that manually pre-compressed .zst files are served
mut req := http.new_request(.get, '${localserver}/precompressed_zstd.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify it's the pre-compressed content
large_content := 'X'.repeat(2000)
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'manual .zst should be valid: ${err}'
return
}
assert decompressed.bytestr() == large_content
}
fn test_gzip_fallback_when_zstd_not_supported() {
// When client only supports gzip, gzip should be used
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip', 'should fallback to gzip when zstd not supported'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
// Tests for enable_static_gzip only (backward compatibility)
fn test_gzip_only_serves_gzip() {
// Test that enable_static_gzip alone works (backward compatibility)
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip', 'gzip-only mode should serve gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_gzip_only_ignores_zstd_request() {
// Test that enable_static_gzip does NOT serve zstd even if client supports it
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd, gzip')
x := req.do()!
assert x.status() == .ok
// Should serve gzip, NOT zstd (because only enable_static_gzip is set)
assert x.header.get(.content_encoding)? == 'gzip', 'gzip-only mode should serve gzip even when client supports zstd'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_gzip_only_no_compression_without_gzip_header() {
// Test that enable_static_gzip does not compress when client doesn't accept gzip
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd') // Only zstd, no gzip
x := req.do()!
assert x.status() == .ok
// Should not have content-encoding header (no compression)
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'gzip-only mode should not compress when client only accepts zstd'
}
// Tests for enable_static_zstd only
fn test_zstd_only_serves_zstd() {
// Test that enable_static_zstd alone works
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd', 'zstd-only mode should serve zstd'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify the body is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zstd_only_ignores_gzip_request() {
// Test that enable_static_zstd does not serve gzip even if client supports it
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip, zstd')
x := req.do()!
assert x.status() == .ok
// Should serve zstd, not gzip (because only enable_static_zstd is set)
assert x.header.get(.content_encoding)? == 'zstd', 'zstd-only mode should serve zstd even when client supports gzip'
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zstd_only_no_compression_without_zstd_header() {
// Test that enable_static_zstd does not compress when client doesn't accept zstd
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip') // Only gzip, no zstd
x := req.do()!
assert x.status() == .ok
// Should not have content-encoding header (no compression)
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'zstd-only mode should not compress when client only accepts gzip'
}
fn test_static_compression_mime_filter_allows_matching_types() {
mut req := http.new_request(.get, '${localserver_filtered_mimes}/filtered.css', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
assert x.body == filtered_css_content
gz_path := find_cached_static_file('filtered.css', '.gz')
assert gz_path != '', 'allowed MIME type should create a cached compressed file'
}
fn test_static_compression_mime_filter_skips_excluded_types() {
mut req := http.new_request(.get, '${localserver_filtered_mimes}/filtered.svg', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
_ := x.header.get(.content_encoding) or {
assert x.body == filtered_svg_content
return
}
assert false, 'excluded MIME type should not be served from compressed content'
}
@@ -0,0 +1,236 @@
// vtest vflags: -prod
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13003
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct App {
veb.StaticHandler
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
@['/']
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
@['/redirect_root']
pub fn (mut app App) redirect_root(mut ctx Context) veb.Result {
return ctx.redirect('/root.txt')
}
@[post]
pub fn (mut app App) post_request(mut ctx Context) veb.Result {
return ctx.text(ctx.req.data)
}
pub struct Context {
veb.Context
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
run_app_test()
}
fn run_app_test() {
mut app := &App{}
if _ := app.handle_static('testdata', true) {
assert true == false, 'should throw unknown mime type error'
} else {
assert err.msg().starts_with('unknown MIME type for file extension ".what"'), 'throws error on unknown mime type'
}
app.static_mime_types['.what'] = veb.mime_types['.txt']
if _ := app.handle_static('not_found', true) {
assert false, 'should throw directory not found error'
} else {
assert err.msg().starts_with('directory `not_found` does not exist') == true
}
app.handle_static('testdata', true) or { panic(err) }
// Enable markdown content negotiation for testing
app.enable_markdown_negotiation = true
if _ := app.mount_static_folder_at('testdata', 'static') {
assert true == false, 'should throw invalid mount path error'
} else {
assert err.msg() == 'invalid mount path! The path should start with `/`'
}
if _ := app.mount_static_folder_at('not_found', '/static') {
assert true == false, 'should throw mount path does not exist error'
} else {
assert err.msg().starts_with('directory `not_found` does not exist') == true
}
app.mount_static_folder_at('testdata', '/static') or { panic(err) }
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_static_root() {
x := http.get('${localserver}/root.txt')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_route_attribute_root_with_static_handler() {
x := http.get('${localserver}/')!
assert x.status() == .ok
assert x.body == 'Hello V!'
}
fn test_redirect_to_static_root() {
x := http.get('${localserver}/redirect_root')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_scans_subdirs() {
x := http.get('${localserver}/sub_folder/sub.txt')!
assert x.status() == .ok
assert x.body == 'sub'
}
fn test_index_subdirs() {
x := http.get('${localserver}/sub_folder/')!
y := http.get('${localserver}/sub.folder/sub_folder')!
assert x.status() == .ok
assert x.body.trim_space() == 'OK'
assert y.status() == .ok
assert y.body.trim_space() == 'OK'
}
fn test_custom_mime_types() {
x := http.get('${localserver}/unknown_mime.what')!
assert x.status() == .ok
assert x.header.get(.content_type)? == veb.mime_types['.txt']
assert x.body.trim_space() == 'unknown_mime'
}
fn test_custom_folder_mount() {
x := http.get('${localserver}/static/root.txt')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_upper_case_mime_type() {
x := http.get('${localserver}/upper_case.TXT')!
assert x.status() == .ok
assert x.body == 'body'
}
// Content negotiation tests - Priority order
// Tests verify: path.md > path.html.md > path/index.html.md
fn test_markdown_negotiation_priority_first() {
// When all three variants exist, path.md (priority 1) is served
config := http.FetchConfig{
url: '${localserver}/about'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('This is the about page in markdown format.')
assert !x.body.contains('about.html.md variant')
assert !x.body.contains('about/index.html.md variant')
}
fn test_markdown_negotiation_priority_second() {
// When only path.html.md exists (priority 2), it is served
config := http.FetchConfig{
url: '${localserver}/page'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('# Page HTML Markdown')
}
fn test_markdown_negotiation_directory_index() {
// For directories, index.html.md is served when Accept: text/markdown
config := http.FetchConfig{
url: '${localserver}/sub_folder/'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('# Index HTML Markdown')
}
// Direct access tests - Verifies backward compatibility
fn test_markdown_direct_access() {
// Without Accept header
x_no_header := http.get('${localserver}/test.md')!
assert x_no_header.status() == .ok
assert x_no_header.header.get(.content_type)? == 'text/markdown'
assert x_no_header.body.contains('# Test Markdown')
// With Accept: text/markdown header - same result
config := http.FetchConfig{
url: '${localserver}/test.md'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x_with_header := http.fetch(config)!
assert x_with_header.status() == .ok
assert x_with_header.header.get(.content_type)? == 'text/markdown'
assert x_with_header.body.contains('# Test Markdown')
}
fn test_markdown_variants_direct_access() {
// All markdown variants remain accessible via their full paths
x_html_md := http.get('${localserver}/about.html.md')!
assert x_html_md.status() == .ok
assert x_html_md.body.contains('about.html.md variant')
x_index := http.get('${localserver}/about/index.html.md')!
assert x_index.status() == .ok
assert x_index.body.contains('about/index.html.md variant')
}
// Negative tests - Verifies correct behavior without Accept header
fn test_markdown_no_negotiation_without_header() {
// Without Accept: text/markdown, content is not found for directories with no index.html
x := http.get('${localserver}/about')!
assert x.status() == .not_found
}
+3
View File
@@ -0,0 +1,3 @@
# About HTML Markdown
This is about.html.md variant.
+3
View File
@@ -0,0 +1,3 @@
# About Page
This is the about page in markdown format.
+3
View File
@@ -0,0 +1,3 @@
# About Index HTML Markdown
This is about/index.html.md variant.
+1
View File
@@ -0,0 +1 @@
<html><body>HTML Page</body></html>
+3
View File
@@ -0,0 +1,3 @@
# Page HTML Markdown
This is a page.html.md file.
+1
View File
@@ -0,0 +1 @@
root
@@ -0,0 +1 @@
OK
@@ -0,0 +1 @@
sub
+1
View File
@@ -0,0 +1 @@
OK
@@ -0,0 +1,3 @@
# Index HTML Markdown
This is an index.html.md file in a subfolder.
+1
View File
@@ -0,0 +1 @@
sub
+3
View File
@@ -0,0 +1,3 @@
# Test Markdown
This is a test markdown file.
+1
View File
@@ -0,0 +1 @@
unknown_mime
+1
View File
@@ -0,0 +1 @@
body
@@ -0,0 +1,63 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import os
import veb
import time
import net.http
const app_port = 29123
const exit_after = 10 * time.second
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
veb.Controller
veb.StaticHandler
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
type AliasApp = App
type AliasContext = Context
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_aliased_app_compiles_and_starts() {
mut app := &AliasApp{}
spawn veb.run_at[AliasApp, AliasContext](mut app,
port: app_port
timeout_in_seconds: 2
family: .ip
)
eprintln('waiting for app to start ...')
_ := <-app.started
eprintln('>>> app was started')
}
fn test_static_root() {
x := http.get('http://127.0.0.1:${app_port}/')!
eprintln('>>>> http request was sent and received')
assert x.status() == .ok
assert x.body == 'index success'
}
@['/'; get]
fn (mut app AliasApp) index(mut ctx AliasContext) veb.Result {
return ctx.text('index success')
}
+143
View File
@@ -0,0 +1,143 @@
// vtest build: present_sqlite3? && !windows // present_sqlite3?: imports db.sqlite; !windows: fasthttp.Server.run not implemented
import veb
import time
import net.http
import db.sqlite
const port = 13004
pub struct Context {
veb.Context
pub mut:
cid int
}
pub fn (mut ctx Context) before_request() {
dump(@LOCATION)
ctx.cid = 123
}
pub struct App {
pub mut:
db sqlite.DB
started chan bool
}
pub fn (mut app App) before_accept_loop() {
dump(@LOCATION)
app.started <- true
}
struct Article {
id int
title string
text string
}
fn test_veb_application_compiles() {
spawn fn () {
time.sleep(15 * time.second)
exit(0)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2)
// app startup time
_ := <-app.started
res := http.fetch(url: 'http://127.0.0.1:${port}/get_cid')!
assert res.status_code == 200
assert res.body == '123'
okres := http.fetch(url: 'http://127.0.0.1:${port}/ok')!
assert okres.status_code == 200
assert okres.body == '{"success":true,"result":123}'
hello := http.fetch(url: 'http://127.0.0.1:${port}/hello')!
assert hello.status_code == 200
assert hello.body == 'hello world'
}
@['/new_article'; post]
pub fn (mut app App) new_article(mut ctx Context) veb.Result {
title := ctx.form['title']
text := ctx.form['text']
if title == '' || text == '' {
return ctx.text('Empty text/title')
}
article := Article{
title: title
text: text
}
println('posting article')
println(article)
sql app.db {
insert article into Article
} or {}
return ctx.redirect('/', typ: .see_other)
}
pub fn (mut app App) time(mut ctx Context) veb.Result {
return ctx.text(time.now().format())
}
pub fn (mut app App) time_json(mut ctx Context) veb.Result {
return ctx.json({
'time': time.now().format()
})
}
fn (app App) hello(mut ctx Context) veb.Result {
return ctx.text('hello world')
}
fn (mut app App) time_json_pretty(mut ctx Context) veb.Result {
return ctx.json_pretty({
'time': time.now().format()
})
}
struct ApiSuccessResponse[T] {
success bool
result T
}
fn (mut app App) json_success[T](mut ctx Context, result T) {
response := ApiSuccessResponse[T]{
success: true
result: result
}
ctx.json(response)
}
// should compile, this is a helper method, not exposed as a route
fn (mut app App) some_helper[T](result T) ApiSuccessResponse[T] {
response := ApiSuccessResponse[T]{
success: true
result: result
}
return response
}
// should compile, the route method itself is not generic
fn (mut app App) ok(mut ctx Context) veb.Result {
return ctx.json(app.some_helper(ctx.cid))
}
// should compile, the route method itself is not generic
fn (mut app App) get_cid(mut ctx Context) veb.Result {
return ctx.text(ctx.cid.str())
}
struct ExampleStruct {
example int
}
fn (mut app App) request_raw_2(mut ctx Context) veb.Result {
stuff := []ExampleStruct{}
app.request_raw(mut ctx, stuff)
return ctx.ok('')
}
// should compile, this is a helper method, not exposed as a route
fn (mut app App) request_raw(mut ctx Context, foo []ExampleStruct) {
ctx.text('Hello world')
}
@@ -0,0 +1,135 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import os
import log
import time
import veb
import net.http
const vexe = os.getenv('VEXE')
const vroot = os.dir(vexe)
const port = 48872
const welcome_text = 'Welcome to our simple veb server'
// Use a known good http client like `curl` (if it exists):
const curl_executable = os.find_abs_path_of_executable('curl') or { '' }
const curl_ok = curl_supports_ipv6()
fn curl_supports_ipv6() bool {
if curl_executable == '' {
return false
}
curl_res := os.execute('${curl_executable} --version')
if curl_res.exit_code != 0 {
return false
}
if !curl_res.output.match_glob('curl*Features: * IPv6 *') {
return false
}
return true
}
fn testsuite_begin() {
log.set_level(.debug)
log.debug(@FN)
os.chdir(vroot) or {}
if curl_ok {
log.info('working curl_executable found at: ${curl_executable}')
} else {
log.warn('no working working curl_executable found')
}
start_services()
}
fn testsuite_end() {
log.debug(@FN)
}
//
fn ensure_curl_works(tname string) ? {
if !curl_ok {
log.warn('skipping test ${tname}, since it needs a working curl')
return none
}
}
fn test_curl_connecting_through_ipv4_works() {
ensure_curl_works(@FN) or { return }
res := os.execute('${curl_executable} --connect-timeout 0.5 --silent http://127.0.0.1:${port}/')
assert res.exit_code == 0, res.output
assert res.output == welcome_text
log.info('> ${@FN}')
}
fn test_net_http_connecting_through_ipv4_works() {
res := http.get('http://127.0.0.1:${port}/')!
assert res.status_code == 200, res.str()
assert res.status_msg == 'OK', res.str()
assert res.body == welcome_text, res.str()
log.info('> ${@FN}')
}
fn test_curl_connecting_through_ipv6_works() {
ensure_curl_works(@FN) or { return }
res := os.execute('${curl_executable} --silent --connect-timeout 0.5 http://[::1]:${port}/')
assert res.exit_code == 0, res.output
assert res.output == welcome_text
log.info('> ${@FN}')
}
fn test_net_http_connecting_through_ipv6_works() {
$if windows {
log.warn('skipping test ${@FN} on windows for now')
return
}
$if freebsd {
// if the sysctl net.inet.ip.connect_inaddr_wild doesn't exist or
// if it axists and is non-zero, we should expect the IPv6 socket to be open.
result := os.execute('sysctl net.inet.ip.connect_inaddr_wild')
if result.exit_code == 0
&& result.output.trim_space() == 'net.inet.ip.connect_inaddr_wild: 0' {
log.warn('skipping test ${@FN} on FreeBSD because sysctl setting net.inet.ip.connect_inaddr_wild=0')
return
}
}
res := http.get('http://[::1]:${port}/')!
assert res.status_code == 200, res.str()
assert res.status_msg == 'OK', res.str()
assert res.body == welcome_text, res.str()
log.info('> ${@FN}')
}
//
pub struct Context {
veb.Context
}
pub struct App {
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text(welcome_text)
}
fn start_services() {
log.debug('starting watchdog thread to ensure the test will always exit one way or another...')
spawn fn (timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
log.error('Timeout of ${timeout_in_ms} ms reached, for webserver: pid: ${os.getpid()}. Exiting ...')
exit(1)
}(10_000)
log.debug('starting webserver...')
mut app := &App{}
spawn veb.run[App, Context](mut app, port)
_ := <-app.started
log.debug('webserver started')
}
+407
View File
@@ -0,0 +1,407 @@
// vtest retry: 3
// vtest build: !sanitized_job? && !windows // !windows: fasthttp.Server.run not implemented yet
import os
import time
import x.json2 as json
import net
import net.http
import io
const sport = 13005
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 12000
// milliseconds
const vexe = os.getenv('VEXE')
const veb_logfile = os.getenv('VEB_LOGFILE')
const vroot = os.dir(vexe)
const serverexe = os.join_path(os.cache_dir(), 'veb_test_server.exe')
const tcp_r_timeout = 10 * time.second
const tcp_w_timeout = 10 * time.second
// setup of veb webserver
fn testsuite_begin() {
os.chdir(vroot) or {}
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn test_simple_veb_app_can_be_compiled() {
// did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v')
did_server_compile :=
os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v')
assert did_server_compile == 0
assert os.exists(serverexe)
}
fn test_a_simple_veb_app_runs_in_the_background() {
mut suffix := ''
$if !windows {
suffix = ' > /dev/null &'
}
if veb_logfile != '' {
suffix = ' 2>> ${os.quoted_path(veb_logfile)} >> ${os.quoted_path(veb_logfile)} &'
}
server_exec_cmd := '${os.quoted_path(serverexe)} ${sport} ${exit_after_time} ${suffix}'
$if debug_net_socket_client ? {
eprintln('running:\n${server_exec_cmd}')
}
$if windows {
spawn os.system(server_exec_cmd)
} $else {
res := os.system(server_exec_cmd)
assert res == 0
}
$if macos {
time.sleep(1000 * time.millisecond)
} $else {
time.sleep(100 * time.millisecond)
}
}
// web client tests follow
fn assert_common_headers(received string) {
assert received.starts_with('HTTP/1.1 200 OK\r\n'), received
assert received.contains('Server: veb\r\n'), received
assert received.contains('Content-Length:'), received
assert received.contains('Connection: close\r\n'), received
}
fn test_a_simple_tcp_client_can_connect_to_the_veb_server() {
received := simple_tcp_client(path: '/') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/plain'), received
assert received.contains('Content-Length: 14'), received
assert received.ends_with('Welcome to veb'), received
}
fn test_a_simple_tcp_client_simple_route() {
received := simple_tcp_client(path: '/simple') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/plain')
assert received.contains('Content-Length: 15')
assert received.ends_with('A simple result')
}
fn test_a_simple_tcp_client_zero_content_length() {
// tests that sending a content-length header of 0 doesn't hang on a read timeout
watch := time.new_stopwatch(auto_start: true)
simple_tcp_client(path: '/', headers: 'Content-Length: 0\r\n\r\n') or {
assert err.msg() == ''
return
}
assert watch.elapsed() < 1 * time.second
}
fn test_timeout_after_delayed_body() {
// content length is 10, but we don't send anything. The request should timeout,
// but not error.
watch := time.new_stopwatch(auto_start: true)
res := simple_tcp_client(
path: '/json_echo'
headers: 'Content-Length: 10\r\n\r\n'
method_str: 'POST'
) or {
assert err.msg() == ''
return
}
assert res.ends_with('408 Request Timeout')
}
fn test_a_simple_tcp_client_html_page() {
received := simple_tcp_client(path: '/html_page') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/html')
assert received.ends_with('<h1>ok</h1>')
}
// net.http client based tests follow:
fn assert_common_http_headers(x http.Response) ! {
assert x.status() == .ok
assert x.header.get(.server) or { '' } == 'veb'
assert (x.header.get(.content_length) or { '0' }).int() > 0
}
fn test_http_client_index() {
x := http.get('http://${localserver}/') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/plain'
assert x.body == 'Welcome to veb'
// The default http client keeps connections alive (it no longer sends
// `Connection: close`), so veb must not answer with a close either.
if conn_header := x.header.get(.connection) {
assert conn_header != 'close'
}
// Opting out of connection reuse restores the historical behavior
// end-to-end: the client sends `Connection: close` and veb honors it.
y := http.fetch(url: 'http://${localserver}/', disable_connection_reuse: true) or { panic(err) }
assert y.header.get(.connection)? == 'close'
}
fn test_http_client_404() {
server := 'http://${localserver}'
url_404_list := [
'/zxcnbnm',
'/JHKAJA',
'/unknown',
]
for url in url_404_list {
res := http.get('${server}${url}') or { panic(err) }
assert res.status() == .not_found
assert res.body == '404 on "${url}"'
}
}
fn test_http_client_simple() {
x := http.get('http://${localserver}/simple') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/plain'
assert x.body == 'A simple result'
}
fn test_http_client_html_page() {
x := http.get('http://${localserver}/html_page') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/html'
assert x.body == '<h1>ok</h1>'
}
fn test_http_client_settings_page() {
x := http.get('http://${localserver}/bilbo/settings') or { panic(err) }
assert_common_http_headers(x)!
assert x.body == 'username: bilbo'
y := http.get('http://${localserver}/kent/settings') or { panic(err) }
assert_common_http_headers(y)!
assert y.body == 'username: kent'
}
fn test_http_client_user_repo_settings_page() {
x := http.get('http://${localserver}/bilbo/gostamp/settings') or { panic(err) }
assert_common_http_headers(x)!
assert x.body == 'username: bilbo | repository: gostamp'
y := http.get('http://${localserver}/kent/golang/settings') or { panic(err) }
assert_common_http_headers(y)!
assert y.body == 'username: kent | repository: golang'
z := http.get('http://${localserver}/missing/golang/settings') or { panic(err) }
assert z.status() == .not_found
}
struct User {
name string
age int
}
fn test_http_client_json_post() {
ouser := User{
name: 'Bilbo'
age: 123
}
json_for_ouser := json.encode(ouser)
mut x := http.post_json('http://${localserver}/json_echo', json_for_ouser) or { panic(err) }
$if debug_net_socket_client ? {
eprintln('/json_echo endpoint response: ${x}')
}
assert x.header.get(.content_type)? == 'application/json'
assert x.body == json_for_ouser
nuser := json.decode[User](x.body) or { User{} }
assert '${ouser}' == '${nuser}'
x = http.post_json('http://${localserver}/json', json_for_ouser) or { panic(err) }
$if debug_net_socket_client ? {
eprintln('/json endpoint response: ${x}')
}
assert x.header.get(.content_type)? == 'application/json'
assert x.body == json_for_ouser
nuser2 := json.decode[User](x.body) or { User{} }
assert '${ouser}' == '${nuser2}'
}
fn test_http_client_multipart_form_data() {
mut form_config := http.PostMultipartFormConfig{
form: {
'foo': 'baz buzz'
}
}
mut x := http.post_multipart_form('http://${localserver}/form_echo', form_config)!
$if debug_net_socket_client ? {
eprintln('/form_echo endpoint response: ${x}')
}
assert x.body == form_config.form['foo']
mut files := []http.FileData{}
files << http.FileData{
filename: 'veb'
content_type: 'text'
data: '"veb test"'
}
mut form_config_files := http.PostMultipartFormConfig{
files: {
'file': files
}
}
x = http.post_multipart_form('http://${localserver}/file_echo', form_config_files)!
$if debug_net_socket_client ? {
eprintln('/form_echo endpoint response: ${x}')
}
assert x.body == files[0].data
}
fn test_login_with_multipart_form_data_send_by_fetch() {
mut form_config := http.PostMultipartFormConfig{
form: {
'username': 'myusername'
'password': 'mypassword123'
}
}
x := http.post_multipart_form('http://${localserver}/login', form_config)!
assert x.status_code == 200
assert x.status_msg == 'OK'
assert x.body == 'username: xmyusernamex | password: xmypassword123x'
}
fn test_query_params_are_passed_as_arguments() {
x := http.get('http://${localserver}/query_echo?c=3&a="test"&b=20')!
assert x.status() == .ok
assert x.body == 'a: x"test"x | b: x20x'
}
fn test_host() {
mut req := http.Request{
url: 'http://${localserver}/with_host'
method: .get
}
mut x := req.do()!
assert x.status() == .not_found
req.add_header(.host, 'example.com')
x = req.do()!
assert x.status() == .ok
}
fn test_empty_response_body_has_content_length() {
req := http.Request{
url: 'http://${localserver}/empty_response_body'
method: .get
}
mut x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_length)? == '0'
}
fn test_http_client_shutdown_does_not_work_without_a_cookie() {
x := http.get('http://${localserver}/shutdown') or {
assert err.msg() == ''
return
}
assert x.status() == .not_found
}
fn testsuite_end() {
// This test is guaranteed to be called last.
// It sends a request to the server to shutdown.
x := http.fetch(
url: 'http://${localserver}/shutdown'
method: .get
cookies: {
'skey': 'superman'
}
) or {
assert err.msg() == ''
return
}
assert x.status() == .ok
assert x.body == 'good bye'
}
// utility code:
struct SimpleTcpClientConfig {
retries int = 4
host string = 'static.dev'
path string = '/'
agent string = 'v/net.tcp.v'
headers string = '\r\n'
content string
method_str string = 'GET'
}
fn simple_tcp_client(config SimpleTcpClientConfig) !string {
mut client := &net.TcpConn(unsafe { nil })
mut tries := 0
for tries < config.retries {
tries++
eprintln('> client retries: ${tries}')
client = net.dial_tcp(localserver) or {
eprintln('dial error: ${err.msg()}')
if tries > config.retries {
return err
}
time.sleep(100 * time.millisecond)
continue
}
break
}
if client == unsafe { nil } {
eprintln('could not create a tcp client connection to http://${localserver} after ${config.retries} retries')
exit(1)
}
client.set_read_timeout(tcp_r_timeout)
client.set_write_timeout(tcp_w_timeout)
defer {
client.close() or {}
}
message := '${config.method_str} ${config.path} HTTP/1.1
Host: ${config.host}
User-Agent: ${config.agent}
Accept: */*
Connection: close
${config.headers}
${config.content}'
$if debug_net_socket_client ? {
eprintln('sending:\n${message}')
}
client.write(message.bytes())!
read := io.read_all(reader: client)!
$if debug_net_socket_client ? {
eprintln('received:\n${read}')
}
return read.bytestr()
}
// for issue 20476
// phenomenon: parsing url error when querypath is `//`
fn test_empty_querypath() {
mut x := http.get('http://${localserver}') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}/') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}//') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}///') or { panic(err) }
assert x.body == 'Welcome to veb'
}
fn test_large_response() {
received := simple_tcp_client(path: '/large_response') or { panic(err) }
assert_common_headers(received)
assert received.ends_with('}]')
assert received.len == 830778
}
+180
View File
@@ -0,0 +1,180 @@
module main
import os
import veb
import time
const known_users = ['bilbo', 'kent']
struct ServerContext {
veb.Context
}
// Custom 404 page
pub fn (mut ctx ServerContext) not_found() veb.Result {
ctx.res.set_status(.not_found)
return ctx.html('404 on "${ctx.req.url}"')
}
pub struct ServerApp {
mut:
server &veb.Server = unsafe { nil }
port int
timeout int
global_config Config
}
struct Config {
max_ping int
}
fn exit_after_timeout(timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
println('>> webserver: pid: ${os.getpid()}, exiting ...')
exit(0)
}
fn main() {
if os.args.len != 3 {
panic('Usage: `veb_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`')
}
http_port := os.args[1].int()
assert http_port > 0
timeout := os.args[2].int()
assert timeout > 0
spawn exit_after_timeout(timeout)
mut app := &ServerApp{
port: http_port
timeout: timeout
global_config: Config{
max_ping: 50
}
}
eprintln('>> webserver: pid: ${os.getpid()}, started on http://localhost:${app.port}/ , with maximum runtime of ${app.timeout} milliseconds.')
veb.run_at[ServerApp, ServerContext](mut app,
host: 'localhost'
port: http_port
family: .ip
timeout_in_seconds: 2
)!
}
// init_server stores the veb server handle when lifecycle control is available.
pub fn (mut app ServerApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app ServerApp) index(mut ctx ServerContext) veb.Result {
assert app.global_config.max_ping == 50
return ctx.text('Welcome to veb')
}
pub fn (mut app ServerApp) simple(mut ctx ServerContext) veb.Result {
return ctx.text('A simple result')
}
pub fn (mut app ServerApp) html_page(mut ctx ServerContext) veb.Result {
return ctx.html('<h1>ok</h1>')
}
// the following serve custom routes
@['/:user/settings']
pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) veb.Result {
if username !in known_users {
return ctx.not_found()
}
return ctx.html('username: ${username}')
}
@['/:user/:repo/settings']
pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username string, repository string) veb.Result {
if username !in known_users {
return ctx.not_found()
}
return ctx.html('username: ${username} | repository: ${repository}')
}
@['/json_echo'; post]
pub fn (mut app ServerApp) json_echo(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.req.data)
}
@['/login'; post]
pub fn (mut app ServerApp) login_form(mut ctx ServerContext, username string, password string) veb.Result {
return ctx.html('username: x${username}x | password: x${password}x')
}
@['/form_echo'; post]
pub fn (mut app ServerApp) form_echo(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.form['foo'])
}
@['/file_echo'; post]
pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) veb.Result {
if 'file' !in ctx.files {
ctx.res.set_status(.internal_server_error)
return ctx.text('no file')
}
return ctx.text(ctx.files['file'][0].data)
}
@['/query_echo']
pub fn (mut app ServerApp) query_echo(mut ctx ServerContext, a string, b int) veb.Result {
return ctx.text('a: x${a}x | b: x${b}x')
}
// Make sure [post] works without the path
@[post]
pub fn (mut app ServerApp) json(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.req.data)
}
@[host: 'example.com']
@['/with_host']
pub fn (mut app ServerApp) with_host(mut ctx ServerContext) veb.Result {
return ctx.ok('')
}
@['/empty_response_body']
pub fn (mut app ServerApp) empty_response_body(mut ctx ServerContext) veb.Result {
return ctx.ok('')
}
pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) veb.Result {
session_key := ctx.get_cookie('skey') or { return ctx.not_found() }
if session_key != 'superman' {
return ctx.not_found()
}
spawn app.exit_gracefully()
return ctx.ok('good bye')
}
fn (mut app ServerApp) exit_gracefully() {
eprintln('>> webserver: exit_gracefully')
time.sleep(100 * time.millisecond)
exit(0)
}
pub fn (mut app ServerApp) large_response(mut ctx ServerContext) veb.Result {
struct Tick {
date_time string
timestamp i64
value f64
}
mut arr := []Tick{}
for i in 0 .. 16000 {
arr << Tick{
date_time: i.str()
timestamp: i
value: i
}
}
return ctx.json(arr)
}