Initial project import
This commit is contained in:
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'
|
||||
}
|
||||
@@ -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!'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)!
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# About HTML Markdown
|
||||
|
||||
This is about.html.md variant.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# About Page
|
||||
|
||||
This is the about page in markdown format.
|
||||
@@ -0,0 +1,3 @@
|
||||
# About Index HTML Markdown
|
||||
|
||||
This is about/index.html.md variant.
|
||||
+1
@@ -0,0 +1 @@
|
||||
<html><body>HTML Page</body></html>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Page HTML Markdown
|
||||
|
||||
This is a page.html.md file.
|
||||
+1
@@ -0,0 +1 @@
|
||||
root
|
||||
@@ -0,0 +1 @@
|
||||
OK
|
||||
@@ -0,0 +1 @@
|
||||
sub
|
||||
@@ -0,0 +1 @@
|
||||
OK
|
||||
@@ -0,0 +1,3 @@
|
||||
# Index HTML Markdown
|
||||
|
||||
This is an index.html.md file in a subfolder.
|
||||
@@ -0,0 +1 @@
|
||||
sub
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Test Markdown
|
||||
|
||||
This is a test markdown file.
|
||||
@@ -0,0 +1 @@
|
||||
unknown_mime
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user