Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,177 @@
|
||||
# Assets
|
||||
|
||||
The asset manager for veb. You can use this asset manager to minify CSS and JavaScript files,
|
||||
combine them into a single file and to make sure the asset you're using exists.
|
||||
|
||||
## Usage
|
||||
|
||||
Add `AssetManager` to your App struct to use the asset manager.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v
|
||||
module main
|
||||
|
||||
import veb
|
||||
import veb.assets
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub mut:
|
||||
am assets.AssetManager
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
### Including assets
|
||||
|
||||
If you want to include an asset in your templates you can use the `include` method.
|
||||
First pass the type of asset (css or js), then specify the "include name" of an asset.
|
||||
|
||||
**Example:**
|
||||
|
||||
```html
|
||||
@{app.am.include(.css, 'main.css')}
|
||||
```
|
||||
|
||||
Will generate
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
```
|
||||
|
||||
### Adding assets
|
||||
|
||||
To add an asset use the `add` method. You must specify the path of the asset and what its
|
||||
include name will be: the name that you will use in templates.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// add a css file at the path "css/main.css" and set its include name to "main.css"
|
||||
app.am.add(.css, 'css/main.css', 'main.css')
|
||||
```
|
||||
|
||||
### Minify and Combine assets
|
||||
|
||||
If you want to minify each asset you must set the `minify` field and specify the cache
|
||||
folder. Each assest you add is minifed and outputted in `cache_dir`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub struct App {
|
||||
pub mut:
|
||||
am assets.AssetManager = assets.AssetManager{
|
||||
cache_dir: 'dist'
|
||||
minify: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To combine the all currently added assets into a single file you must call the `combine` method
|
||||
and specify which asset type you want to combine.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// `combine` returns the path of the minified file
|
||||
minified_file := app.am.combine(.css)!
|
||||
```
|
||||
|
||||
### Handle folders
|
||||
|
||||
You can use the asset manger in combination with veb's `StaticHandler` to serve
|
||||
assets in a folder as static assets.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
am assets.AssetManager
|
||||
}
|
||||
```
|
||||
|
||||
Let's say we have the following folder structure:
|
||||
|
||||
```
|
||||
assets/
|
||||
├── css/
|
||||
│ └── main.css
|
||||
└── js/
|
||||
└── main.js
|
||||
```
|
||||
|
||||
We can tell the asset manager to add all assets in the `static` folder
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
// add all assets in the "assets" folder
|
||||
app.am.handle_assets('assets')!
|
||||
// serve all files in the "assets" folder as static files
|
||||
app.handle_static('assets', false)!
|
||||
// start the app
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
The include name of each minified asset will be set to its relative path,
|
||||
so if you want to include `main.css` in your template you would write
|
||||
`@{app.am.include('css/main.css')}`
|
||||
|
||||
#### Minify
|
||||
|
||||
If you add an asset folder and want to minify those assets you can call the
|
||||
`cleanup_cache` method to remove old files from the cache folder
|
||||
that are no longer needed.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
am assets.AssetManager = assets.AssetManager{
|
||||
cache_dir: 'dist'
|
||||
minify: true
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
// add all assets in the "assets" folder
|
||||
app.am.handle_assets('assets')!
|
||||
// remove all old cached files from the cache folder
|
||||
app.am.cleanup_cache()!
|
||||
// serve all files in the "assets" folder as static files
|
||||
app.handle_static('assets', false)!
|
||||
// start the app
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
#### Prefix the include name
|
||||
|
||||
You can add a custom prefix to the include name of assets when adding a folder.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// add all assets in the "assets" folder
|
||||
app.am.handle_assets_at('assets', 'static')!
|
||||
```
|
||||
|
||||
Now if you want to include `main.css` you would write
|
||||
``@{app.am.include('static/css/main.css')}`
|
||||
@@ -0,0 +1,312 @@
|
||||
module assets
|
||||
|
||||
import crypto.md5
|
||||
import os
|
||||
import strings
|
||||
import time
|
||||
import veb
|
||||
|
||||
pub enum AssetType {
|
||||
css
|
||||
js
|
||||
all
|
||||
}
|
||||
|
||||
pub struct Asset {
|
||||
pub:
|
||||
kind AssetType
|
||||
file_path string
|
||||
last_modified time.Time
|
||||
include_name string
|
||||
}
|
||||
|
||||
pub struct AssetManager {
|
||||
mut:
|
||||
css []Asset
|
||||
js []Asset
|
||||
cached_file_names []string
|
||||
pub mut:
|
||||
// when true assets will be minified
|
||||
minify bool
|
||||
// the directory to store the cached/combined files
|
||||
cache_dir string
|
||||
// how a combined file should be named. For example for css the extension '.css'
|
||||
// will be added to the end of `combined_file_name`
|
||||
combined_file_name string = 'combined'
|
||||
}
|
||||
|
||||
fn (mut am AssetManager) add_asset_directory(directory_path string, traversed_path string) ! {
|
||||
files := os.ls(directory_path)!
|
||||
if files.len > 0 {
|
||||
for file in files {
|
||||
full_path := os.join_path(directory_path, file)
|
||||
relative_path := os.join_path(traversed_path, file)
|
||||
|
||||
if os.is_dir(full_path) {
|
||||
am.add_asset_directory(full_path, relative_path)!
|
||||
} else {
|
||||
ext := os.file_ext(full_path)
|
||||
match ext {
|
||||
'.css' { am.add(.css, full_path, relative_path)! }
|
||||
'.js' { am.add(.js, full_path, relative_path)! }
|
||||
// ignore non css/js files
|
||||
else {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle_assets recursively walks `directory_path` and adds any assets to the asset manager
|
||||
pub fn (mut am AssetManager) handle_assets(directory_path string) ! {
|
||||
return am.add_asset_directory(directory_path, '')
|
||||
}
|
||||
|
||||
// handle_assets_at recursively walks `directory_path` and adds any assets to the asset manager.
|
||||
// The include name of assets are prefixed with `prepend`
|
||||
pub fn (mut am AssetManager) handle_assets_at(directory_path string, prepend string) ! {
|
||||
// remove trailing '/'
|
||||
return am.add_asset_directory(directory_path, prepend.trim_right('/'))
|
||||
}
|
||||
|
||||
// get all assets of type `asset_type`
|
||||
pub fn (am AssetManager) get_assets(asset_type AssetType) []Asset {
|
||||
return match asset_type {
|
||||
.css {
|
||||
am.css
|
||||
}
|
||||
.js {
|
||||
am.js
|
||||
}
|
||||
.all {
|
||||
mut assets := []Asset{}
|
||||
assets << am.css
|
||||
assets << am.js
|
||||
assets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add an asset to the asset manager
|
||||
pub fn (mut am AssetManager) add(asset_type AssetType, file_path string, include_name string) ! {
|
||||
if asset_type == .all {
|
||||
return error('cannot minify asset of type "all"')
|
||||
}
|
||||
if !os.exists(file_path) {
|
||||
return error('cannot add asset: file "${file_path}" does not exist')
|
||||
}
|
||||
|
||||
last_modified_unix := os.file_last_mod_unix(file_path)
|
||||
|
||||
mut real_path := file_path
|
||||
|
||||
if am.minify {
|
||||
// minify and cache file if it was modified
|
||||
output_path, is_cached := am.minify_and_cache(asset_type, real_path, last_modified_unix,
|
||||
include_name)!
|
||||
|
||||
if is_cached == false && am.exists(asset_type, include_name) {
|
||||
// file was not modified between the last call to `add`
|
||||
// and the file was already in the asset manager, so we don't need to
|
||||
// add it again
|
||||
return
|
||||
}
|
||||
|
||||
real_path = output_path
|
||||
}
|
||||
|
||||
asset := Asset{
|
||||
kind: asset_type
|
||||
file_path: real_path
|
||||
last_modified: time.unix(last_modified_unix)
|
||||
include_name: include_name
|
||||
}
|
||||
|
||||
match asset_type {
|
||||
.css { am.css << asset }
|
||||
.js { am.js << asset }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut am AssetManager) minify_and_cache(asset_type AssetType, file_path string, last_modified i64, include_name string) !(string, bool) {
|
||||
if asset_type == .all {
|
||||
return error('cannot minify asset of type "all"')
|
||||
}
|
||||
|
||||
if am.cache_dir == '' {
|
||||
return error('cannot minify asset: cache directory is not valid')
|
||||
} else if !os.exists(am.cache_dir) {
|
||||
os.mkdir_all(am.cache_dir)!
|
||||
}
|
||||
|
||||
cache_key := am.get_cache_key(file_path, last_modified)
|
||||
output_file := '${cache_key}.${asset_type}'
|
||||
output_path := os.join_path(am.cache_dir, output_file)
|
||||
|
||||
if os.exists(output_path) {
|
||||
// the output path already exists, this means that the file has
|
||||
// been minifed and cached before and hasn't changed in the meantime
|
||||
am.cached_file_names << output_file
|
||||
return output_path, false
|
||||
} else {
|
||||
// check if the file has been minified before, but is modified.
|
||||
// if that's the case we remove the old cached file
|
||||
cached_files := os.ls(am.cache_dir)!
|
||||
hash := cache_key.all_before('-')
|
||||
for file in cached_files {
|
||||
if file.starts_with(hash) {
|
||||
os.rm(os.join_path(am.cache_dir, file))!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
txt := os.read_file(file_path)!
|
||||
minified := match asset_type {
|
||||
.css { minify_css(txt) }
|
||||
.js { minify_js(txt) }
|
||||
else { '' }
|
||||
}
|
||||
|
||||
os.write_file(output_path, minified)!
|
||||
|
||||
am.cached_file_names << output_file
|
||||
return output_path, true
|
||||
}
|
||||
|
||||
fn (mut am AssetManager) get_cache_key(file_path string, last_modified i64) string {
|
||||
abs_path := if os.is_abs_path(file_path) { file_path } else { os.resource_abs_path(file_path) }
|
||||
hash := md5.sum(abs_path.bytes())
|
||||
return '${hash.hex()}-${last_modified}'
|
||||
}
|
||||
|
||||
// cleanup_cache removes all files in the cache directory that aren't cached at the time
|
||||
// this function is called
|
||||
pub fn (mut am AssetManager) cleanup_cache() ! {
|
||||
if am.cache_dir == '' {
|
||||
return error('[veb.assets]: cache directory is not valid')
|
||||
}
|
||||
cached_files := os.ls(am.cache_dir)!
|
||||
|
||||
// loop over all the files in the cache directory. If a file isn't cached, remove it
|
||||
for file in cached_files {
|
||||
ext := os.file_ext(file)
|
||||
if ext !in ['.css', '.js'] || file in am.cached_file_names {
|
||||
continue
|
||||
} else if !file.starts_with(am.combined_file_name) {
|
||||
os.rm(os.join_path(am.cache_dir, file))!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if an asset is already added to the asset manager
|
||||
pub fn (am AssetManager) exists(asset_type AssetType, include_name string) bool {
|
||||
assets := am.get_assets(asset_type)
|
||||
|
||||
return assets.any(it.include_name == include_name)
|
||||
}
|
||||
|
||||
// include css/js files in your veb app from templates
|
||||
// Usage example:
|
||||
// ```html
|
||||
// @{app.am.include(.css, 'main.css')}
|
||||
// ```
|
||||
pub fn (am AssetManager) include(asset_type AssetType, include_name string) veb.RawHtml {
|
||||
assets := am.get_assets(asset_type)
|
||||
for asset in assets {
|
||||
if asset.include_name == include_name {
|
||||
// always add link/src from root of web server ('/css/main.css'),
|
||||
// but leave absolute paths intact
|
||||
mut real_path := asset.file_path
|
||||
if real_path[0] != `/` && !os.is_abs_path(real_path) {
|
||||
real_path = '/${asset.file_path}'
|
||||
}
|
||||
|
||||
return match asset_type {
|
||||
.css {
|
||||
'<link rel="stylesheet" href="${real_path}">'
|
||||
}
|
||||
.js {
|
||||
'<script src="${real_path}"></script>'
|
||||
}
|
||||
else {
|
||||
eprintln('[veb.assets] can only include css or js assets')
|
||||
''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln('[veb.assets] no asset with include name "${include_name}" exists!')
|
||||
return ''
|
||||
}
|
||||
|
||||
// combine assets of type `asset_type` into a single file and return the outputted file path.
|
||||
// If you call `combine` with asset type `all` the function will return an empty string,
|
||||
// the minified files will be available at `combined_file_name`.`asset_type`
|
||||
pub fn (mut am AssetManager) combine(asset_type AssetType) !string {
|
||||
if asset_type == .all {
|
||||
am.combine(.css)!
|
||||
am.combine(.js)!
|
||||
return ''
|
||||
}
|
||||
if am.cache_dir == '' {
|
||||
return error('cannot combine assets: cache directory is not valid')
|
||||
} else if !os.exists(am.cache_dir) {
|
||||
os.mkdir_all(am.cache_dir)!
|
||||
}
|
||||
|
||||
assets := am.get_assets(asset_type)
|
||||
combined_file_path := os.join_path(am.cache_dir, '${am.combined_file_name}.${asset_type}')
|
||||
mut f := os.create(combined_file_path)!
|
||||
|
||||
for asset in assets {
|
||||
bytes := os.read_bytes(asset.file_path)!
|
||||
f.write(bytes)!
|
||||
f.write_string('\n')!
|
||||
}
|
||||
|
||||
f.close()
|
||||
|
||||
return combined_file_path
|
||||
}
|
||||
|
||||
// TODO: implement proper minification
|
||||
@[manualfree]
|
||||
pub fn minify_css(css string) string {
|
||||
mut lines := css.split('\n')
|
||||
// estimate arbitrary number of characters for a line of css
|
||||
mut sb := strings.new_builder(lines.len * 20)
|
||||
defer {
|
||||
unsafe { sb.free() }
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
trimmed := line.trim_space()
|
||||
if trimmed != '' {
|
||||
sb.write_string(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.str()
|
||||
}
|
||||
|
||||
// TODO: implement proper minification
|
||||
@[manualfree]
|
||||
pub fn minify_js(js string) string {
|
||||
mut lines := js.split('\n')
|
||||
// estimate arbitrary number of characters for a line of js
|
||||
mut sb := strings.new_builder(lines.len * 40)
|
||||
defer {
|
||||
unsafe { sb.free() }
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
trimmed := line.trim_space()
|
||||
if trimmed != '' {
|
||||
sb.write_string(trimmed)
|
||||
sb.write_u8(` `)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.str()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import veb.assets
|
||||
import os
|
||||
|
||||
const base_cache_dir = os.join_path(os.vtmp_dir(), 'veb_assets_test_cache')
|
||||
|
||||
fn testsuite_begin() {
|
||||
os.mkdir_all(base_cache_dir) or {}
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
os.rmdir_all(base_cache_dir) or {}
|
||||
}
|
||||
|
||||
// clean_cache_dir used before and after tests that write to a cache directory.
|
||||
// Because of parallel compilation and therefore test running,
|
||||
// unique cache dirs are needed per test function.
|
||||
fn clean_cache_dir(dir string) {
|
||||
os.rmdir_all(dir) or {}
|
||||
}
|
||||
|
||||
fn cache_dir(test_name string) string {
|
||||
return os.join_path(base_cache_dir, test_name)
|
||||
}
|
||||
|
||||
fn get_test_file_path(file string) string {
|
||||
path := os.join_path(base_cache_dir, file)
|
||||
os.rm(path) or {}
|
||||
os.write_file(path, get_test_file_contents(file)) or { panic(err) }
|
||||
return path
|
||||
}
|
||||
|
||||
fn get_test_file_contents(file string) string {
|
||||
contents := match file {
|
||||
'test1.js' { '{"one": 1}\n' }
|
||||
'test2.js' { '{"two": 2}\n' }
|
||||
'test1.css' { '.one {\n\tcolor: #336699;\n}\n' }
|
||||
'test2.css' { '.two {\n\tcolor: #996633;\n}\n' }
|
||||
else { 'wibble\n' }
|
||||
}
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
fn test_add() {
|
||||
mut am := assets.AssetManager{}
|
||||
|
||||
mut errored := false
|
||||
am.add(.css, 'test.css', 'test.css') or { errored = true }
|
||||
assert errored == true, 'am.add should error'
|
||||
|
||||
errored = false
|
||||
am.add(.css, get_test_file_path('test1.css'), 'included.css') or {
|
||||
eprintln(err)
|
||||
errored = true
|
||||
}
|
||||
assert errored == false, 'am.add should not error'
|
||||
|
||||
css_assets := am.get_assets(.css)
|
||||
assert css_assets.len == 1
|
||||
assert css_assets[0].file_path == get_test_file_path('test1.css')
|
||||
assert css_assets[0].include_name == 'included.css'
|
||||
}
|
||||
|
||||
fn test_add_minify_missing_cache_dir() {
|
||||
mut am := assets.AssetManager{
|
||||
minify: true
|
||||
}
|
||||
mut errored := false
|
||||
am.add(.js, get_test_file_path('test1.css'), 'included.js') or {
|
||||
assert err.msg() == 'cannot minify asset: cache directory is not valid'
|
||||
errored = true
|
||||
}
|
||||
|
||||
assert errored == true, 'am.add should return an error'
|
||||
}
|
||||
|
||||
fn test_add_minified() {
|
||||
mut am := assets.AssetManager{
|
||||
minify: true
|
||||
cache_dir: cache_dir('test_add_minified')
|
||||
}
|
||||
clean_cache_dir(am.cache_dir)
|
||||
|
||||
am.add(.js, get_test_file_path('test1.js'), 'included.js')!
|
||||
|
||||
js_assets := am.get_assets(.js)
|
||||
assert js_assets.len == 1
|
||||
assert js_assets[0].file_path.starts_with(am.cache_dir) == true
|
||||
}
|
||||
|
||||
fn test_combine() {
|
||||
mut am := assets.AssetManager{
|
||||
cache_dir: cache_dir('test_combine')
|
||||
}
|
||||
clean_cache_dir(am.cache_dir)
|
||||
|
||||
am.add(.css, get_test_file_path('test1.css'), 'test1.css')!
|
||||
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
|
||||
|
||||
combined_path := am.combine(.css)!
|
||||
combined := os.read_file(combined_path)!
|
||||
|
||||
expected := get_test_file_contents('test1.css') + '\n' + get_test_file_contents('test2.css') +
|
||||
'\n'
|
||||
assert combined == expected
|
||||
}
|
||||
|
||||
fn test_combine_minified() {
|
||||
// minify test is simple for now, because assets are not properly minified yet
|
||||
mut am := assets.AssetManager{
|
||||
cache_dir: cache_dir('test_combine_minified')
|
||||
minify: true
|
||||
}
|
||||
clean_cache_dir(am.cache_dir)
|
||||
|
||||
am.add(.css, get_test_file_path('test1.css'), 'test1.css')!
|
||||
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
|
||||
|
||||
combined_path := am.combine(.css)!
|
||||
combined := os.read_file(combined_path)!
|
||||
|
||||
// minified version should be 2 lines + one extra newline
|
||||
assert combined.split('\n').len == 3
|
||||
}
|
||||
|
||||
fn test_minify_cache_last_modified() {
|
||||
mut am := assets.AssetManager{
|
||||
minify: true
|
||||
cache_dir: cache_dir('test_cache_last_modified')
|
||||
}
|
||||
clean_cache_dir(am.cache_dir)
|
||||
|
||||
// first we write the file and add it
|
||||
am.add(.js, get_test_file_path('test1.js'), 'included.js')!
|
||||
mut js_assets := am.get_assets(.js)
|
||||
assert js_assets.len == 1
|
||||
old_cached_path := js_assets[0].file_path
|
||||
|
||||
// then we only add the file, the file is not modified so the "last modified is the same".
|
||||
// we expect that the asset manager doesn't cache a minified file if it hasn't been changed
|
||||
// the last time it was added
|
||||
am.add(.js, os.join_path(base_cache_dir, 'test1.js'), 'included.js')!
|
||||
|
||||
js_assets = am.get_assets(.js)
|
||||
// check if the file isn't added twice
|
||||
assert js_assets.len == 1
|
||||
// if the file path was not modified, veb.assets didn't overwrite the file
|
||||
assert js_assets[0].file_path == old_cached_path
|
||||
}
|
||||
|
||||
fn test_cleanup_cache() {
|
||||
mut am := assets.AssetManager{
|
||||
minify: true
|
||||
cache_dir: cache_dir('test_cleanup_cache')
|
||||
}
|
||||
clean_cache_dir(am.cache_dir)
|
||||
// manually make the cache dir
|
||||
os.mkdir_all(am.cache_dir) or {}
|
||||
|
||||
// write a file to the cache dir isn't added to the asset manager to represent
|
||||
// a previously cached file
|
||||
path1 := os.join_path(am.cache_dir, 'test1.css')
|
||||
os.write_file(path1, 'h1 { color: red; }')!
|
||||
assert os.exists(path1) == true
|
||||
|
||||
// add a file to the asset manager and write it
|
||||
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
|
||||
css_assets := am.get_assets(.css)
|
||||
// get the cached path
|
||||
assert css_assets.len == 1
|
||||
path2 := css_assets[0].file_path
|
||||
assert os.exists(path2) == true
|
||||
|
||||
am.cleanup_cache()!
|
||||
|
||||
// the first asset wasn't added to the asset manager, so it should not exist
|
||||
assert os.exists(path1) == false
|
||||
assert os.exists(path2) == true
|
||||
}
|
||||
|
||||
fn test_include() {
|
||||
mut am := assets.AssetManager{}
|
||||
|
||||
css_path := get_test_file_path('test1.css')
|
||||
js_path := get_test_file_path('test1.js')
|
||||
am.add(.css, css_path, 'other.css')!
|
||||
am.add(.js, js_path, 'js/test.js')!
|
||||
|
||||
assert am.include(.css, 'other.css') == '<link rel="stylesheet" href="${css_path}">'
|
||||
assert am.include(.js, 'js/test.js') == '<script src="${js_path}"></script>'
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
## Description
|
||||
|
||||
`veb.auth` is a module that helps with common logic required for authentication.
|
||||
|
||||
It allows to easily generate hashed and salted passwords and to compare password hashes.
|
||||
|
||||
It also handles authentication tokens, including DB table creation and insertion.
|
||||
All DBs are supported.
|
||||
|
||||
## Usage
|
||||
|
||||
```v
|
||||
import veb
|
||||
import db.pg
|
||||
import veb.auth
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
db pg.DB
|
||||
auth auth.Auth[pg.DB] // or auth.Auth[sqlite.DB] etc
|
||||
}
|
||||
|
||||
const port = 8081
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
current_user User
|
||||
}
|
||||
|
||||
struct User {
|
||||
id int @[primary; sql: serial]
|
||||
name string
|
||||
password_hash string
|
||||
salt string
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{
|
||||
// Use your actual local PostgreSQL password here.
|
||||
db: pg.connect(host: 'localhost', user: 'postgres', password: 'password', dbname: 'postgres')!
|
||||
}
|
||||
app.auth = auth.new(app.db)
|
||||
veb.run[App, Context](mut app, port)
|
||||
}
|
||||
|
||||
@[post]
|
||||
pub fn (mut app App) register_user(mut ctx Context, name string, password string) veb.Result {
|
||||
salt := auth.generate_salt()
|
||||
new_user := User{
|
||||
name: name
|
||||
password_hash: auth.hash_password_with_salt(password, salt)
|
||||
salt: salt
|
||||
}
|
||||
user_id := sql app.db {
|
||||
insert new_user into User
|
||||
} or { 0 }
|
||||
|
||||
// Generate and insert the token using user ID
|
||||
token := app.auth.add_token(user_id) or { '' }
|
||||
// Authenticate the user by adding the token to the cookies
|
||||
ctx.set_cookie(name: 'token', value: token)
|
||||
|
||||
return ctx.redirect('/')
|
||||
}
|
||||
|
||||
@[post]
|
||||
pub fn (mut app App) login_post(mut ctx Context, name string, password string) veb.Result {
|
||||
user := app.find_user_by_name(name) or {
|
||||
ctx.error('Bad credentials')
|
||||
return ctx.redirect('/login')
|
||||
}
|
||||
// Verify user password using veb.auth
|
||||
if !auth.compare_password_with_hash(password, user.salt, user.password_hash) {
|
||||
ctx.error('Bad credentials')
|
||||
return ctx.redirect('/login')
|
||||
}
|
||||
// Find the user token in the Token table
|
||||
token := app.auth.add_token(user.id) or { '' }
|
||||
// Authenticate the user by adding the token to the cookies
|
||||
ctx.set_cookie(name: 'token', value: token)
|
||||
return ctx.redirect('/')
|
||||
}
|
||||
|
||||
pub fn (mut app App) find_user_by_name(name string) ?User {
|
||||
// ... db query
|
||||
return User{}
|
||||
}
|
||||
```
|
||||
|
||||
On macOS, Postgres.app rejects passwordless `trust` connections from unknown
|
||||
processes. Use a real password in the example above instead of `password: ''`.
|
||||
|
||||
## Security considerations
|
||||
|
||||
`hash_password_with_salt` and its related functions use `sha256` for hashing with a single
|
||||
iteration. This is not secure for production use, and you should use a more secure hashing
|
||||
algorithm and multiple iterations.
|
||||
|
||||
See also:
|
||||
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module auth
|
||||
|
||||
import rand
|
||||
import crypto.rand as crypto_rand
|
||||
import crypto.hmac
|
||||
import crypto.sha256
|
||||
|
||||
const max_safe_unsigned_integer = u32(4_294_967_295)
|
||||
|
||||
pub struct Auth[T] {
|
||||
db T
|
||||
// pub:
|
||||
// salt string
|
||||
}
|
||||
|
||||
pub struct Token {
|
||||
pub:
|
||||
id int @[primary; sql: serial]
|
||||
user_id int
|
||||
value string
|
||||
// ip string
|
||||
}
|
||||
|
||||
pub fn new[T](db T) Auth[T] {
|
||||
set_rand_crypto_safe_seed()
|
||||
sql db {
|
||||
create table Token
|
||||
} or { eprintln('veb.auth: failed to create table Token') }
|
||||
return Auth[T]{
|
||||
db: db
|
||||
// salt: generate_salt()
|
||||
}
|
||||
}
|
||||
|
||||
// fn (mut app App) add_token(user_id int, ip string) !string {
|
||||
pub fn (mut app Auth[T]) add_token(user_id int) !string {
|
||||
mut uuid := rand.uuid_v4()
|
||||
token := Token{
|
||||
user_id: user_id
|
||||
value: uuid
|
||||
// ip: ip
|
||||
}
|
||||
sql app.db {
|
||||
insert token into Token
|
||||
}!
|
||||
return uuid
|
||||
}
|
||||
|
||||
pub fn (app &Auth[T]) find_token(value string) ?Token {
|
||||
tokens := sql app.db {
|
||||
select from Token where value == value limit 1
|
||||
} or { []Token{} }
|
||||
if tokens.len == 0 {
|
||||
return none
|
||||
}
|
||||
return tokens.first()
|
||||
}
|
||||
|
||||
pub fn (mut app Auth[T]) delete_tokens(user_id int) ! {
|
||||
sql app.db {
|
||||
delete from Token where user_id == user_id
|
||||
}!
|
||||
}
|
||||
|
||||
pub fn set_rand_crypto_safe_seed() {
|
||||
first_seed := generate_crypto_safe_int_u32()
|
||||
second_seed := generate_crypto_safe_int_u32()
|
||||
rand.seed([first_seed, second_seed])
|
||||
}
|
||||
|
||||
fn generate_crypto_safe_int_u32() u32 {
|
||||
return u32(crypto_rand.int_u64(max_safe_unsigned_integer) or { 0 })
|
||||
}
|
||||
|
||||
pub fn generate_salt() string {
|
||||
return rand.i64().str()
|
||||
}
|
||||
|
||||
pub fn hash_password_with_salt(plain_text_password string, salt string) string {
|
||||
salted_password := '${plain_text_password}${salt}'
|
||||
return sha256.sum(salted_password.bytes()).hex().str()
|
||||
}
|
||||
|
||||
pub fn compare_password_with_hash(plain_text_password string, salt string, hashed string) bool {
|
||||
digest := hash_password_with_salt(plain_text_password, salt)
|
||||
// constant time comparison
|
||||
// I know this is operating on the hex-encoded strings, but it's still constant time
|
||||
// and better than not doing it at all
|
||||
return hmac.equal(digest.bytes(), hashed.bytes())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module auth
|
||||
|
||||
pub struct Request {
|
||||
pub:
|
||||
client_id string
|
||||
client_secret string
|
||||
code string
|
||||
state string
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
module veb
|
||||
|
||||
import net.http
|
||||
|
||||
// max read and write limits in bytes
|
||||
const max_read = int($d('veb_max_read_bytes', 8192))
|
||||
const max_write = int($d('veb_max_write_bytes', 2048))
|
||||
|
||||
pub const max_http_post_size = $d('veb_max_http_post_size_bytes', 1048576)
|
||||
pub const default_port = int($d('veb_default_port', 8080))
|
||||
pub const methods_with_form = [http.Method.post, .put, .patch]
|
||||
|
||||
pub const headers_close = http.new_custom_header_from_map({
|
||||
'Server': 'veb'
|
||||
})!
|
||||
|
||||
pub const http_302 = http.new_response(
|
||||
status: .found
|
||||
body: '302 Found'
|
||||
header: headers_close
|
||||
)
|
||||
|
||||
pub const http_400 = http.new_response(
|
||||
status: .bad_request
|
||||
body: '400 Bad Request'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
)
|
||||
|
||||
pub const http_404 = http.new_response(
|
||||
status: .not_found
|
||||
body: '404 Not Found'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
)
|
||||
|
||||
pub const http_408 = http.new_response(
|
||||
status: .request_timeout
|
||||
body: '408 Request Timeout'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
)
|
||||
|
||||
pub const http_413 = http.new_response(
|
||||
status: .request_entity_too_large
|
||||
body: '413 Request entity is too large'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
)
|
||||
|
||||
pub const http_500 = http.new_response(
|
||||
status: .internal_server_error
|
||||
body: '500 Internal Server Error'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
)
|
||||
|
||||
pub const mime_types = {
|
||||
'.aac': 'audio/aac'
|
||||
'.abw': 'application/x-abiword'
|
||||
'.arc': 'application/x-freearc'
|
||||
'.avi': 'video/x-msvideo'
|
||||
'.azw': 'application/vnd.amazon.ebook'
|
||||
'.bin': 'application/octet-stream'
|
||||
'.bmp': 'image/bmp'
|
||||
'.bz': 'application/x-bzip'
|
||||
'.bz2': 'application/x-bzip2'
|
||||
'.cda': 'application/x-cdf'
|
||||
'.csh': 'application/x-csh'
|
||||
'.css': 'text/css'
|
||||
'.csv': 'text/csv'
|
||||
'.doc': 'application/msword'
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
'.eot': 'application/vnd.ms-fontobject'
|
||||
'.epub': 'application/epub+zip'
|
||||
'.gz': 'application/gzip'
|
||||
'.gif': 'image/gif'
|
||||
'.htm': 'text/html'
|
||||
'.html': 'text/html'
|
||||
'.ico': 'image/vnd.microsoft.icon'
|
||||
'.ics': 'text/calendar'
|
||||
'.jar': 'application/java-archive'
|
||||
'.jpeg': 'image/jpeg'
|
||||
'.jpg': 'image/jpeg'
|
||||
'.js': 'text/javascript'
|
||||
'.json': 'application/json'
|
||||
'.jsonld': 'application/ld+json'
|
||||
'.md': 'text/markdown'
|
||||
'.mid': 'audio/midi audio/x-midi'
|
||||
'.midi': 'audio/midi audio/x-midi'
|
||||
'.mjs': 'text/javascript'
|
||||
'.mp3': 'audio/mpeg'
|
||||
'.mp4': 'video/mp4'
|
||||
'.mpeg': 'video/mpeg'
|
||||
'.mpkg': 'application/vnd.apple.installer+xml'
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation'
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet'
|
||||
'.odt': 'application/vnd.oasis.opendocument.text'
|
||||
'.oga': 'audio/ogg'
|
||||
'.ogv': 'video/ogg'
|
||||
'.ogx': 'application/ogg'
|
||||
'.opus': 'audio/opus'
|
||||
'.otf': 'font/otf'
|
||||
'.png': 'image/png'
|
||||
'.pdf': 'application/pdf'
|
||||
'.php': 'application/x-httpd-php'
|
||||
'.ppt': 'application/vnd.ms-powerpoint'
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
'.rar': 'application/vnd.rar'
|
||||
'.rtf': 'application/rtf'
|
||||
'.scss': 'text/css'
|
||||
'.sh': 'application/x-sh'
|
||||
'.svg': 'image/svg+xml'
|
||||
'.swf': 'application/x-shockwave-flash'
|
||||
'.tar': 'application/x-tar'
|
||||
'.tif': 'image/tiff'
|
||||
'.tiff': 'image/tiff'
|
||||
'.ts': 'video/mp2t'
|
||||
'.ttf': 'font/ttf'
|
||||
'.txt': 'text/plain'
|
||||
'.vsd': 'application/vnd.visio'
|
||||
'.wasm': 'application/wasm'
|
||||
'.wav': 'audio/wav'
|
||||
'.weba': 'audio/webm'
|
||||
'.webm': 'video/webm'
|
||||
'.webp': 'image/webp'
|
||||
'.woff': 'font/woff'
|
||||
'.woff2': 'font/woff2'
|
||||
'.xhtml': 'application/xhtml+xml'
|
||||
'.xls': 'application/vnd.ms-excel'
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
'.xml': 'application/xml'
|
||||
'.xul': 'application/vnd.mozilla.xul+xml'
|
||||
'.zip': 'application/zip'
|
||||
'.zst': 'application/zstd'
|
||||
'.3gp': 'video/3gpp'
|
||||
'.3g2': 'video/3gpp2'
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
'.m3u8': 'application/vnd.apple.mpegurl'
|
||||
'.vsh': 'text/x-vlang'
|
||||
'.v': 'text/x-vlang'
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
module veb
|
||||
|
||||
import compress.gzip
|
||||
import compress.zstd
|
||||
import hash
|
||||
import net
|
||||
import net.http
|
||||
import os
|
||||
import time
|
||||
import x.json2 as json
|
||||
|
||||
enum ContextReturnType {
|
||||
normal
|
||||
file
|
||||
}
|
||||
|
||||
enum ContextTakeoverMode {
|
||||
none
|
||||
manual
|
||||
reusable
|
||||
}
|
||||
|
||||
pub enum RedirectType {
|
||||
found = int(http.Status.found)
|
||||
moved_permanently = int(http.Status.moved_permanently)
|
||||
see_other = int(http.Status.see_other)
|
||||
temporary_redirect = int(http.Status.temporary_redirect)
|
||||
permanent_redirect = int(http.Status.permanent_redirect)
|
||||
}
|
||||
|
||||
// The Context struct represents the Context which holds the HTTP request and response.
|
||||
// It has fields for the query, form, files and methods for handling the request and response
|
||||
@[heap]
|
||||
pub struct Context {
|
||||
mut:
|
||||
// veb will try to infer the content type base on file extension,
|
||||
// and if `content_type` is not empty the `Content-Type` header will always be
|
||||
// set to this value
|
||||
content_type string
|
||||
// done is set to true when a response can be sent over `conn`
|
||||
done bool
|
||||
// If the `Connection: close` header is present the connection should always be closed
|
||||
client_wants_to_close bool
|
||||
// Configuration for static file compression (set by serve_if_static)
|
||||
enable_static_gzip bool
|
||||
enable_static_zstd bool
|
||||
enable_static_compression bool
|
||||
static_compression_max_size int
|
||||
static_compression_mime_types []string
|
||||
// controls whether veb should automatically send the response or whether the handler
|
||||
// takes over response writing.
|
||||
takeover_mode ContextTakeoverMode
|
||||
return_file string
|
||||
custom_mime_types_ref &map[string]string = unsafe { nil }
|
||||
// raw client file descriptor, used by the fasthttp backend to create a TcpConn
|
||||
// on demand when takeover_conn() is called
|
||||
client_fd int = -1
|
||||
// already_compressed indicates that the response body is already compressed (zstd/gzip)
|
||||
// and the compression middlewares should skip it
|
||||
already_compressed bool
|
||||
pub:
|
||||
// TODO: move this to `handle_request`
|
||||
// time.ticks() from start of veb connection handle.
|
||||
// You can use it to determine how much time is spent on your request.
|
||||
page_gen_start i64
|
||||
pub mut:
|
||||
// how the http response should be handled by veb's backend
|
||||
return_type ContextReturnType = .normal
|
||||
req http.Request @[skip]
|
||||
custom_mime_types map[string]string
|
||||
// TCP connection to client. Only for advanced usage!
|
||||
conn &net.TcpConn = unsafe { nil }
|
||||
// Map containing query params for the route.
|
||||
// http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' }
|
||||
query map[string]string
|
||||
// Multipart-form fields.
|
||||
form map[string]string
|
||||
// Files from multipart-form.
|
||||
files map[string][]http.FileData
|
||||
res http.Response
|
||||
// use form_error to pass errors from the context to your frontend
|
||||
form_error string
|
||||
livereload_poll_interval_ms int = 250
|
||||
}
|
||||
|
||||
// returns the request header data from the key
|
||||
pub fn (ctx &Context) get_header(key http.CommonHeader) ?string {
|
||||
return ctx.req.header.get(key)
|
||||
}
|
||||
|
||||
// returns the request header data from the key
|
||||
pub fn (ctx &Context) get_custom_header(key string) ?string {
|
||||
return ctx.req.header.get_custom(key)
|
||||
}
|
||||
|
||||
// set a header on the response object
|
||||
pub fn (mut ctx Context) set_header(key http.CommonHeader, value string) {
|
||||
ctx.res.header.set(key, value)
|
||||
}
|
||||
|
||||
// set a custom header on the response object
|
||||
pub fn (mut ctx Context) set_custom_header(key string, value string) ! {
|
||||
ctx.res.header.set_custom(key, value)!
|
||||
}
|
||||
|
||||
// send_response_to_client finalizes the response headers and sets Content-Type to `mimetype`
|
||||
// and the response body to `response`
|
||||
pub fn (mut ctx Context) send_response_to_client(mimetype string, response string) Result {
|
||||
if ctx.done && ctx.takeover_mode == .none {
|
||||
eprintln('[veb] a response cannot be sent twice over one connection')
|
||||
return Result{}
|
||||
}
|
||||
// ctx.done is only set in this function, so in order to sent a response over the connection
|
||||
// this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly.
|
||||
ctx.done = true
|
||||
if ctx.res.body.len > 0 {
|
||||
unsafe { ctx.res.body.free() }
|
||||
ctx.res.body = ''
|
||||
}
|
||||
$if veb_livereload ? {
|
||||
if mimetype == 'text/html' {
|
||||
ctx.res.body = response.replace('</html>',
|
||||
'<script src="/veb_livereload/${veb_livereload_server_start}/script.js"></script>\n</html>')
|
||||
} else {
|
||||
ctx.res.body = response.clone()
|
||||
}
|
||||
} $else {
|
||||
ctx.res.body = response.clone()
|
||||
}
|
||||
// Prefer explicit overrides from Context state or a pre-set response header.
|
||||
mut custom_mimetype := ctx.content_type
|
||||
if custom_mimetype.len == 0 {
|
||||
custom_mimetype = ctx.res.header.get(.content_type) or { mimetype }
|
||||
}
|
||||
if custom_mimetype != '' {
|
||||
ctx.res.header.set(.content_type, custom_mimetype)
|
||||
}
|
||||
if !ctx.res.header.contains(.content_length) {
|
||||
ctx.res.header.set(.content_length, ctx.res.body.len.str())
|
||||
}
|
||||
// send veb's closing headers
|
||||
ctx.res.header.set(.server, 'veb')
|
||||
if ctx.takeover_mode == .none && ctx.client_wants_to_close {
|
||||
// Only sent the `Connection: close` header when the client wants to close
|
||||
// the connection. This typically happens when the client only supports HTTP 1.0
|
||||
ctx.res.header.set(.connection, 'close')
|
||||
}
|
||||
// set the http version
|
||||
ctx.res.set_version(.v1_1)
|
||||
if ctx.res.status_code == 0 {
|
||||
ctx.res.set_status(.ok)
|
||||
}
|
||||
if ctx.takeover_mode != .none && ctx.conn != unsafe { nil } {
|
||||
fast_send_resp(mut ctx.conn, ctx.res) or {}
|
||||
unsafe { ctx.res.body.free() }
|
||||
ctx.res.body = ''
|
||||
}
|
||||
// result is send in `veb.v`, `handle_route`
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Response with payload and content-type `text/html`
|
||||
pub fn (mut ctx Context) html(s string) Result {
|
||||
return ctx.send_response_to_client('text/html', s)
|
||||
}
|
||||
|
||||
// Response with `s` as payload and content-type `text/plain`
|
||||
pub fn (mut ctx Context) text(s string) Result {
|
||||
return ctx.send_response_to_client('text/plain', s)
|
||||
}
|
||||
|
||||
fn (mut ctx Context) set_static_compression_config(enable_gzip bool, enable_zstd bool, enable_compression bool, max_size int, mime_types []string) {
|
||||
ctx.enable_static_gzip = enable_gzip
|
||||
ctx.enable_static_zstd = enable_zstd
|
||||
ctx.enable_static_compression = enable_compression
|
||||
ctx.static_compression_max_size = max_size
|
||||
ctx.static_compression_mime_types = mime_types
|
||||
}
|
||||
|
||||
// Response with json_s as payload and content-type `application/json`
|
||||
pub fn (mut ctx Context) json[T](j T) Result {
|
||||
json_s := json.encode(j)
|
||||
return ctx.send_response_to_client('application/json', json_s)
|
||||
}
|
||||
|
||||
// Response with a pretty-printed JSON result
|
||||
pub fn (mut ctx Context) json_pretty[T](j T) Result {
|
||||
json_s := json.encode(j, prettify: true)
|
||||
return ctx.send_response_to_client('application/json', json_s)
|
||||
}
|
||||
|
||||
// Response HTTP_OK with file as payload
|
||||
fn (ctx &Context) custom_mime_type(ext string) ?string {
|
||||
if ct := ctx.custom_mime_types[ext] {
|
||||
return ct
|
||||
}
|
||||
if unsafe { ctx.custom_mime_types_ref != nil } {
|
||||
custom_mime_types := unsafe { *ctx.custom_mime_types_ref }
|
||||
return custom_mime_types[ext]
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
pub fn (mut ctx Context) file(file_path string) Result {
|
||||
if !os.exists(file_path) {
|
||||
eprintln('[veb] file "${file_path}" does not exist')
|
||||
return ctx.not_found()
|
||||
}
|
||||
ext := os.file_ext(file_path)
|
||||
mut content_type := ctx.content_type
|
||||
if content_type.len == 0 {
|
||||
if ct := ctx.custom_mime_type(ext) {
|
||||
content_type = ct
|
||||
} else {
|
||||
content_type = mime_types[ext]
|
||||
}
|
||||
}
|
||||
if content_type.len == 0 {
|
||||
eprintln('[veb] no MIME type found for extension "${ext}"')
|
||||
return ctx.server_error('')
|
||||
}
|
||||
return ctx.send_file(content_type, file_path)
|
||||
}
|
||||
|
||||
fn (mut ctx Context) send_file(content_type string, file_path string) Result {
|
||||
mut file := os.open(file_path) or {
|
||||
eprint('[veb] error while trying to open file: ${err.msg()}')
|
||||
ctx.res.set_status(.not_found)
|
||||
return ctx.text('resource does not exist')
|
||||
}
|
||||
// seek from file end to get the file size
|
||||
file.seek(0, .end) or {
|
||||
eprintln('[veb] error while trying to read file: ${err.msg()}')
|
||||
return ctx.server_error('could not read resource')
|
||||
}
|
||||
file_size := file.tell() or {
|
||||
eprintln('[veb] error while trying to read file: ${err.msg()}')
|
||||
return ctx.server_error('could not read resource')
|
||||
}
|
||||
file.close()
|
||||
// Check which encodings the client accepts
|
||||
accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
|
||||
client_accepts_zstd := accept_encoding.contains('zstd')
|
||||
client_accepts_gzip := accept_encoding.contains('gzip')
|
||||
max_size_bytes := ctx.static_compression_max_size
|
||||
should_use_static_compression := ctx.should_use_static_compression_for_mime(content_type)
|
||||
// Determine which compression modes are enabled
|
||||
use_zstd := should_use_static_compression && ((ctx.enable_static_zstd && client_accepts_zstd)
|
||||
|| (ctx.enable_static_compression && client_accepts_zstd))
|
||||
use_gzip := should_use_static_compression && ((ctx.enable_static_gzip && client_accepts_gzip)
|
||||
|| (ctx.enable_static_compression && client_accepts_gzip))
|
||||
// Try to serve pre-compressed files if any compression is enabled
|
||||
if use_zstd || use_gzip {
|
||||
orig_mtime := os.file_last_mod_unix(file_path)
|
||||
// Try zstd first if enabled (better compression), then gzip
|
||||
if use_zstd {
|
||||
if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) {
|
||||
return Result{}
|
||||
}
|
||||
}
|
||||
if use_gzip {
|
||||
if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) {
|
||||
return Result{}
|
||||
}
|
||||
}
|
||||
// No pre-compressed file available: create one if file is small enough
|
||||
if file_size < max_size_bytes {
|
||||
// Load, compress, save, and serve
|
||||
data := os.read_file(file_path) or {
|
||||
eprintln('[veb] error while trying to read file: ${err.msg()}')
|
||||
return ctx.server_error('could not read resource')
|
||||
}
|
||||
// Try zstd first if enabled, then gzip
|
||||
if use_zstd {
|
||||
if result := ctx.serve_compressed_static(content_type, file_path, data, .zstd) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
if use_gzip {
|
||||
if result := ctx.serve_compressed_static(content_type, file_path, data, .gzip) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
// Compression failed: serve uncompressed in streaming mode
|
||||
ctx.return_type = .file
|
||||
ctx.return_file = file_path
|
||||
ctx.res.header.set(.content_length, file_size.str())
|
||||
return ctx.send_response_to_client(content_type, '')
|
||||
}
|
||||
}
|
||||
// Takeover mode: load file in memory (backward compatibility)
|
||||
if ctx.takeover_mode != .none {
|
||||
data := os.read_file(file_path) or {
|
||||
eprintln('[veb] error while trying to read file: ${err.msg()}')
|
||||
return ctx.server_error('could not read resource')
|
||||
}
|
||||
return ctx.send_response_to_client(content_type, data)
|
||||
}
|
||||
// Default: serve uncompressed file in streaming mode (zero-copy sendfile)
|
||||
ctx.return_type = .file
|
||||
ctx.return_file = file_path
|
||||
ctx.res.header.set(.content_length, file_size.str())
|
||||
return ctx.send_response_to_client(content_type, '')
|
||||
}
|
||||
|
||||
fn normalize_static_compression_mime_type(mime_type string) string {
|
||||
return mime_type.all_before(';').trim_space().to_lower()
|
||||
}
|
||||
|
||||
fn (ctx &Context) should_use_static_compression_for_mime(content_type string) bool {
|
||||
if ctx.static_compression_mime_types.len == 0 {
|
||||
return true
|
||||
}
|
||||
normalized_content_type := normalize_static_compression_mime_type(content_type)
|
||||
if normalized_content_type == '' {
|
||||
return false
|
||||
}
|
||||
for mime_type in ctx.static_compression_mime_types {
|
||||
if normalize_static_compression_mime_type(mime_type) == normalized_content_type {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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_compression_cache_path(file_path string, ext string) string {
|
||||
real_file_path := os.real_path(file_path)
|
||||
path_hash := hash.sum64_string(real_file_path, 0).hex_full()
|
||||
app_dir_name := sanitize_cache_path_component(os.base(os.getwd()))
|
||||
static_dir_name := sanitize_cache_path_component(os.base(os.dir(real_file_path)))
|
||||
file_name := sanitize_cache_path_component(os.file_name(real_file_path))
|
||||
return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name, static_dir_name,
|
||||
'${file_name}.${path_hash}${ext}')
|
||||
}
|
||||
|
||||
fn (mut ctx Context) serve_compressed_path_if_fresh(content_type string, compressed_path string, encoding_name string, orig_mtime i64) bool {
|
||||
if !os.exists(compressed_path) {
|
||||
return false
|
||||
}
|
||||
compressed_mtime := os.file_last_mod_unix(compressed_path)
|
||||
if compressed_mtime < orig_mtime {
|
||||
return false
|
||||
}
|
||||
// Serve existing compressed file in streaming mode (zero-copy)
|
||||
ctx.return_type = .file
|
||||
ctx.return_file = compressed_path
|
||||
ctx.res.header.set(.content_encoding, encoding_name)
|
||||
ctx.res.header.set(.vary, 'Accept-Encoding')
|
||||
compressed_size := os.file_size(compressed_path)
|
||||
ctx.res.header.set(.content_length, compressed_size.str())
|
||||
ctx.already_compressed = true
|
||||
ctx.send_response_to_client(content_type, '')
|
||||
return true
|
||||
}
|
||||
|
||||
// serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh.
|
||||
// Returns true if the file was served, false otherwise.
|
||||
fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool {
|
||||
// First prefer manually pre-compressed files beside the original static file.
|
||||
side_by_side_path := '${file_path}${ext}'
|
||||
if ctx.serve_compressed_path_if_fresh(content_type, side_by_side_path, encoding_name,
|
||||
orig_mtime)
|
||||
{
|
||||
return true
|
||||
}
|
||||
// Then try veb-managed cache files under os.cache_dir().
|
||||
cached_path := static_compression_cache_path(file_path, ext)
|
||||
return ctx.serve_compressed_path_if_fresh(content_type, cached_path, encoding_name, orig_mtime)
|
||||
}
|
||||
|
||||
// serve_compressed_static compresses data and serves it, optionally caching to disk.
|
||||
// Returns Result on success, none on compression failure.
|
||||
fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result {
|
||||
compressed, ext, encoding_name := match encoding {
|
||||
.zstd {
|
||||
c := zstd.compress(data.bytes()) or { return none }
|
||||
c, '.zst', 'zstd'
|
||||
}
|
||||
.gzip {
|
||||
c := gzip.compress(data.bytes()) or { return none }
|
||||
c, '.gz', 'gzip'
|
||||
}
|
||||
}
|
||||
|
||||
compressed_path := static_compression_cache_path(file_path, ext)
|
||||
// Try to save compressed version for future requests
|
||||
mut write_success := true
|
||||
cache_dir := os.dir(compressed_path)
|
||||
os.mkdir_all(cache_dir) or {
|
||||
eprintln('[veb] warning: could not create static compression cache dir `${cache_dir}`: ${err.msg()}')
|
||||
write_success = false
|
||||
}
|
||||
if write_success {
|
||||
os.write_file(compressed_path, compressed.bytestr()) or {
|
||||
eprintln('[veb] warning: could not save ${ext} file in static compression cache: ${err.msg()}')
|
||||
write_success = false
|
||||
}
|
||||
}
|
||||
ctx.already_compressed = true
|
||||
if write_success {
|
||||
// Serve the newly cached file in streaming mode (zero-copy)
|
||||
ctx.return_type = .file
|
||||
ctx.return_file = compressed_path
|
||||
ctx.res.header.set(.content_encoding, encoding_name)
|
||||
ctx.res.header.set(.vary, 'Accept-Encoding')
|
||||
ctx.res.header.set(.content_length, compressed.len.str())
|
||||
return ctx.send_response_to_client(content_type, '')
|
||||
} else {
|
||||
// Fallback: serve compressed content from memory (no caching)
|
||||
ctx.res.header.set(.content_encoding, encoding_name)
|
||||
ctx.res.header.set(.vary, 'Accept-Encoding')
|
||||
return ctx.send_response_to_client(content_type, compressed.bytestr())
|
||||
}
|
||||
}
|
||||
|
||||
// Response HTTP_OK with s as payload
|
||||
pub fn (mut ctx Context) ok(s string) Result {
|
||||
mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type }
|
||||
return ctx.send_response_to_client(mime, s)
|
||||
}
|
||||
|
||||
// send an error 400 with a message
|
||||
pub fn (mut ctx Context) request_error(msg string) Result {
|
||||
ctx.res.set_status(.bad_request)
|
||||
return ctx.send_response_to_client('text/plain', msg)
|
||||
}
|
||||
|
||||
// send an error 500 with a message
|
||||
pub fn (mut ctx Context) server_error(msg string) Result {
|
||||
ctx.res.set_status(.internal_server_error)
|
||||
return ctx.send_response_to_client('text/plain', msg)
|
||||
}
|
||||
|
||||
// send an error with a custom status
|
||||
pub fn (mut ctx Context) server_error_with_status(s http.Status) Result {
|
||||
ctx.res.set_status(s)
|
||||
return ctx.send_response_to_client('text/plain', 'Server error')
|
||||
}
|
||||
|
||||
// send a 204 No Content response without body and content-type
|
||||
pub fn (mut ctx Context) no_content() Result {
|
||||
ctx.res.set_status(.no_content)
|
||||
return ctx.send_response_to_client('', '')
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct RedirectParams {
|
||||
pub:
|
||||
typ RedirectType
|
||||
}
|
||||
|
||||
// Redirect to an url
|
||||
pub fn (mut ctx Context) redirect(url string, params RedirectParams) Result {
|
||||
status := http.Status(params.typ)
|
||||
ctx.res.set_status(status)
|
||||
ctx.res.header.add(.location, url)
|
||||
return ctx.send_response_to_client('text/plain', status.str())
|
||||
}
|
||||
|
||||
// before_request is *always* the first function that is executed.
|
||||
// It can be overriden on your custom context. You can use it to
|
||||
// log information about the current request, like its target url,
|
||||
// client IP etc, or to enrich the request with common information,
|
||||
// by setting session cookies, adding custom headers etc.
|
||||
// For more control over the request, use the explicit Middleware
|
||||
// support described in https://modules.vlang.io/veb.html#middleware .
|
||||
pub fn (mut ctx Context) before_request() {
|
||||
}
|
||||
|
||||
// returns a HTTP 404 response
|
||||
pub fn (mut ctx Context) not_found() Result {
|
||||
ctx.res.set_status(.not_found)
|
||||
return ctx.send_response_to_client('text/plain', '404 Not Found')
|
||||
}
|
||||
|
||||
// Gets a cookie by a key
|
||||
pub fn (ctx &Context) get_cookie(key string) ?string {
|
||||
if cookie := ctx.req.cookie(key) {
|
||||
return cookie.value
|
||||
} else {
|
||||
return none
|
||||
}
|
||||
}
|
||||
|
||||
// Sets a cookie
|
||||
pub fn (mut ctx Context) set_cookie(cookie http.Cookie) {
|
||||
cookie_raw := cookie.str()
|
||||
if cookie_raw == '' {
|
||||
eprintln('[veb] error setting cookie: name of cookie is invalid.\n${cookie}')
|
||||
return
|
||||
}
|
||||
ctx.res.header.add(.set_cookie, cookie_raw)
|
||||
}
|
||||
|
||||
// set_content_type sets the Content-Type header to `mime`
|
||||
pub fn (mut ctx Context) set_content_type(mime string) {
|
||||
ctx.content_type = mime
|
||||
}
|
||||
|
||||
// takeover_conn prevents veb from automatically sending a response and closing
|
||||
// the connection. You are responsible for closing the connection.
|
||||
// In takeover mode if you call a Context method the response will be directly
|
||||
// send over the connection and you can send multiple responses.
|
||||
// This function is useful when you want to keep the connection alive and/or
|
||||
// send multiple responses. Like with the SSE.
|
||||
pub fn (mut ctx Context) takeover_conn() {
|
||||
ctx.takeover_mode = .manual
|
||||
ctx.prepare_takeover_conn()
|
||||
}
|
||||
|
||||
fn (mut ctx Context) prepare_takeover_conn() {
|
||||
if ctx.conn == unsafe { nil } && ctx.client_fd >= 0 {
|
||||
// For the fasthttp backend: create a TcpConn from the raw fd on demand.
|
||||
// Set the fd to blocking mode. fasthttp uses non-blocking sockets,
|
||||
// but TcpConn.write() expects blocking behavior for reliable writes.
|
||||
$if !windows {
|
||||
flags := C.fcntl(ctx.client_fd, C.F_GETFL, 0)
|
||||
if flags != -1 {
|
||||
C.fcntl(ctx.client_fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
|
||||
}
|
||||
}
|
||||
ctx.conn = &net.TcpConn{
|
||||
sock: net.TcpSocket{
|
||||
Socket: net.Socket{
|
||||
handle: ctx.client_fd
|
||||
}
|
||||
}
|
||||
handle: ctx.client_fd
|
||||
is_blocking: true
|
||||
read_timeout: 30 * time.second
|
||||
write_timeout: 30 * time.second
|
||||
}
|
||||
} else if ctx.conn != unsafe { nil } {
|
||||
// The connection exists but uses non-blocking I/O.
|
||||
// Switch to blocking mode for reliable SSE writes.
|
||||
fd := ctx.conn.handle
|
||||
$if !windows {
|
||||
flags := C.fcntl(fd, C.F_GETFL, 0)
|
||||
if flags != -1 {
|
||||
C.fcntl(fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
|
||||
}
|
||||
}
|
||||
ctx.conn.is_blocking = true
|
||||
ctx.conn.set_read_timeout(30 * time.second)
|
||||
ctx.conn.set_write_timeout(30 * time.second)
|
||||
}
|
||||
}
|
||||
|
||||
// takeover_conn_reusable prevents veb from automatically sending a response,
|
||||
// but lets veb keep the connection in the read loop after the handler returns.
|
||||
// The handler must write exactly one complete HTTP response with a clear body
|
||||
// boundary, such as Content-Length or a final chunk for Transfer-Encoding:
|
||||
// chunked. If the client asked to close the connection, veb will still close it.
|
||||
pub fn (mut ctx Context) takeover_conn_reusable() {
|
||||
ctx.takeover_mode = .reusable
|
||||
ctx.prepare_takeover_conn()
|
||||
}
|
||||
|
||||
// user_agent returns the user-agent header for the current client
|
||||
pub fn (ctx &Context) user_agent() string {
|
||||
return ctx.req.header.get(.user_agent) or { '' }
|
||||
}
|
||||
|
||||
// Returns the ip address from the current user
|
||||
pub fn (ctx &Context) ip() string {
|
||||
mut ip := ctx.req.header.get_custom('CF-Connecting-IP') or { '' }
|
||||
if ip == '' {
|
||||
ip = ctx.req.header.get(.x_forwarded_for) or { '' }
|
||||
}
|
||||
if ip == '' {
|
||||
ip = ctx.req.header.get_custom('X-Forwarded-For') or { '' }
|
||||
}
|
||||
if ip == '' {
|
||||
ip = ctx.req.header.get_custom('X-Real-Ip') or { '' }
|
||||
}
|
||||
if ip.contains(',') {
|
||||
ip = ip.all_before(',')
|
||||
}
|
||||
if ip == '' && ctx.conn != unsafe { nil } {
|
||||
ip = ctx.conn.peer_ip() or { '' }
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// time_to_render returns the time in milliseconds that it took to render the page
|
||||
pub fn (ctx &Context) time_to_render() i64 {
|
||||
if ctx.page_gen_start == 0 {
|
||||
return 0
|
||||
}
|
||||
return time.ticks() - ctx.page_gen_start
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
module veb
|
||||
|
||||
import net.urllib
|
||||
|
||||
type ControllerHandler = fn (ctx &Context, mut url urllib.URL, host string) &Context
|
||||
|
||||
pub struct ControllerPath {
|
||||
pub:
|
||||
path string
|
||||
handler ControllerHandler = unsafe { nil }
|
||||
pub mut:
|
||||
host string
|
||||
}
|
||||
|
||||
interface ControllerInterface {
|
||||
controllers []&ControllerPath
|
||||
}
|
||||
|
||||
pub struct Controller {
|
||||
pub mut:
|
||||
controllers []&ControllerPath
|
||||
}
|
||||
|
||||
// register_controller adds a new Controller to your app
|
||||
pub fn (mut c Controller) register_controller[A, X](path string, mut global_app A) ! {
|
||||
c.controllers << controller[A, X](path, mut global_app)!
|
||||
}
|
||||
|
||||
// controller generates a new Controller for the main app
|
||||
pub fn controller[A, X](path string, mut global_app A) !&ControllerPath {
|
||||
routes := generate_routes[A, X](global_app) or { panic(err.msg()) }
|
||||
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes) or {
|
||||
panic(err.msg())
|
||||
}
|
||||
|
||||
// generate struct with closure so the generic type is encapsulated in the closure
|
||||
// no need to type `ControllerHandler` as generic since it's not needed for closures
|
||||
return &ControllerPath{
|
||||
path: path
|
||||
handler: fn [mut global_app, path, routes, controllers_sorted] [A, X](ctx &Context, mut url urllib.URL, host string) &Context {
|
||||
// transform the url
|
||||
url.path = url.path.all_after_first(path)
|
||||
if url.path != '' && !url.path.starts_with('/') {
|
||||
url.path = '/' + url.path
|
||||
}
|
||||
|
||||
// match controller paths
|
||||
$if A is ControllerInterface {
|
||||
if completed_context := handle_controllers[X](controllers_sorted, ctx, mut url,
|
||||
host)
|
||||
{
|
||||
return completed_context
|
||||
}
|
||||
}
|
||||
|
||||
// create a new user context and pass the veb's context
|
||||
mut user_context := X{}
|
||||
user_context.Context = ctx
|
||||
|
||||
handle_route[A, X](mut global_app, mut user_context, url, host, &routes)
|
||||
// Preserve the handled context on the heap before the stack-local user context goes away.
|
||||
unsafe {
|
||||
*ctx = user_context.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// register_controller adds a new Controller to your app
|
||||
pub fn (mut c Controller) register_host_controller[A, X](host string, path string, mut global_app A) ! {
|
||||
c.controllers << controller_host[A, X](host, path, mut global_app)!
|
||||
}
|
||||
|
||||
// controller_host generates a controller which only handles incoming requests from the `host` domain
|
||||
pub fn controller_host[A, X](host string, path string, mut global_app A) &ControllerPath {
|
||||
mut ctrl := controller[A, X](path, mut global_app)
|
||||
ctrl.host = host
|
||||
return ctrl
|
||||
}
|
||||
|
||||
fn check_duplicate_routes_in_controllers[T](global_app &T, routes map[string]Route) ![]&ControllerPath {
|
||||
mut controllers_sorted := []&ControllerPath{}
|
||||
$if T is ControllerInterface {
|
||||
mut paths := []string{}
|
||||
controllers_sorted = global_app.controllers.clone()
|
||||
controllers_sorted.sort(a.path.len > b.path.len)
|
||||
for controller in controllers_sorted {
|
||||
if controller.host == '' {
|
||||
if controller.path in paths {
|
||||
return error('conflicting paths: duplicate controller handling the route "${controller.path}"')
|
||||
}
|
||||
paths << controller.path
|
||||
}
|
||||
}
|
||||
for method_name, route in routes {
|
||||
for controller_path in paths {
|
||||
if route.path.starts_with(controller_path) {
|
||||
return error('conflicting paths: method "${method_name}" with route "${route.path}" should be handled by the Controller of path "${controller_path}"')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return controllers_sorted
|
||||
}
|
||||
|
||||
fn handle_controllers[X](controllers []&ControllerPath, ctx &Context, mut url urllib.URL, host string) ?&Context {
|
||||
for controller in controllers {
|
||||
// skip controller if the hosts don't match
|
||||
if controller.host != '' && host != controller.host {
|
||||
continue
|
||||
}
|
||||
if url.path.len >= controller.path.len && url.path.starts_with(controller.path) {
|
||||
// pass route handling to the controller
|
||||
return controller.handler(ctx, mut url, host)
|
||||
}
|
||||
}
|
||||
|
||||
return none
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
module veb
|
||||
|
||||
import net.http
|
||||
import net.urllib
|
||||
import os
|
||||
|
||||
struct RegressionContext {
|
||||
Context
|
||||
}
|
||||
|
||||
struct AppStaticRegressionApp {
|
||||
Controller
|
||||
StaticHandler
|
||||
}
|
||||
|
||||
struct AppStaticRootController {}
|
||||
|
||||
fn (app &AppStaticRootController) index(mut ctx RegressionContext) Result {
|
||||
return ctx.text('from app static root controller')
|
||||
}
|
||||
|
||||
struct ControllerStaticRootBase {
|
||||
StaticHandler
|
||||
}
|
||||
|
||||
struct ControllerStaticRootController {
|
||||
ControllerStaticRootBase
|
||||
}
|
||||
|
||||
fn (app &ControllerStaticRootController) index(mut ctx RegressionContext) Result {
|
||||
return ctx.text('from controller static root controller')
|
||||
}
|
||||
|
||||
fn test_app_static_is_served_before_root_controller() {
|
||||
root_file := regression_testdata_path('root.txt')
|
||||
sub_dir := regression_testdata_path('sub_folder')
|
||||
sub_file := os.join_path(sub_dir, 'sub.txt')
|
||||
|
||||
mut root_controller := AppStaticRootController{}
|
||||
mut app := AppStaticRegressionApp{}
|
||||
app.serve_static('/root.txt', root_file) or { panic(err) }
|
||||
app.handle_static(sub_dir, true) or { panic(err) }
|
||||
app.register_controller[AppStaticRootController, RegressionContext]('/', mut root_controller) or {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
completed_root := regression_handle_app_request(mut app, regression_request('/root.txt'))
|
||||
assert completed_root.return_type == .file
|
||||
assert completed_root.return_file == root_file
|
||||
|
||||
completed_sub := regression_handle_app_request(mut app, regression_request('/sub.txt'))
|
||||
assert completed_sub.return_type == .file
|
||||
assert completed_sub.return_file == sub_file
|
||||
}
|
||||
|
||||
fn test_controller_static_keeps_root_relative_paths() {
|
||||
root_file := regression_testdata_path('root.txt')
|
||||
sub_dir := regression_testdata_path('sub_folder')
|
||||
sub_file := os.join_path(sub_dir, 'sub.txt')
|
||||
|
||||
mut controller_app := ControllerStaticRootController{}
|
||||
controller_app.serve_static('/root.txt', root_file) or { panic(err) }
|
||||
controller_app.handle_static(sub_dir, true) or { panic(err) }
|
||||
|
||||
ctrl_path := controller[ControllerStaticRootController, RegressionContext]('/', mut
|
||||
controller_app) or { panic(err) }
|
||||
|
||||
completed_root := regression_handle_controller_request(ctrl_path, '/root.txt')
|
||||
assert completed_root.return_type == .file
|
||||
assert completed_root.return_file == root_file
|
||||
|
||||
completed_sub := regression_handle_controller_request(ctrl_path, '/sub.txt')
|
||||
assert completed_sub.return_type == .file
|
||||
assert completed_sub.return_file == sub_file
|
||||
}
|
||||
|
||||
fn regression_request(url string) http.Request {
|
||||
return http.Request{
|
||||
method: .get
|
||||
url: url
|
||||
header: http.new_custom_header_from_map({
|
||||
'Host': '127.0.0.1'
|
||||
}) or { panic(err) }
|
||||
}
|
||||
}
|
||||
|
||||
fn regression_testdata_path(rel string) string {
|
||||
return os.join_path(os.dir(@FILE), 'tests', 'testdata', rel)
|
||||
}
|
||||
|
||||
fn regression_handle_controller_request(ctrl_path &ControllerPath, path string) &Context {
|
||||
req := regression_request(path)
|
||||
mut url := urllib.parse_request_uri(path) or { panic(err) }
|
||||
ctx := &Context{
|
||||
req: req
|
||||
}
|
||||
return ctrl_path.handler(ctx, mut url, '')
|
||||
}
|
||||
|
||||
fn regression_handle_app_request(mut app AppStaticRegressionApp, req http.Request) &Context {
|
||||
routes := generate_routes[AppStaticRegressionApp, RegressionContext](app) or { panic(err) }
|
||||
controllers_sorted := check_duplicate_routes_in_controllers[AppStaticRegressionApp](app, routes) or {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
params := RequestParams{
|
||||
global_app: unsafe { voidptr(&app) }
|
||||
controllers_sorted: controllers_sorted
|
||||
routes: &routes
|
||||
benchmark_page_generation: false
|
||||
}
|
||||
return handle_request_and_route[AppStaticRegressionApp, RegressionContext](mut app, req, 0,
|
||||
params)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
module veb
|
||||
|
||||
import net.http
|
||||
import net.urllib
|
||||
|
||||
const issue_20757_allowed_origin = 'https://vlang.io'
|
||||
const issue_20757_route = '/before_request'
|
||||
const issue_20757_secret_key = 'secretkey'
|
||||
|
||||
struct Issue20757Context {
|
||||
Context
|
||||
}
|
||||
|
||||
fn (mut ctx Issue20757Context) before_request() {
|
||||
if ctx.req.url != issue_20757_route {
|
||||
return
|
||||
}
|
||||
ctx.set_custom_header('X-BEFORE-REQUEST', 'true') or {}
|
||||
key := ctx.get_header(.authorization) or { 'Basic none' }
|
||||
if key != 'Basic ${issue_20757_secret_key}' {
|
||||
ctx.request_error('Authorization failed')
|
||||
}
|
||||
}
|
||||
|
||||
struct Issue20757App {
|
||||
Middleware[Issue20757Context]
|
||||
}
|
||||
|
||||
@['/before_request']
|
||||
fn (app &Issue20757App) authorized(mut ctx Issue20757Context) Result {
|
||||
return ctx.text('authorized')
|
||||
}
|
||||
|
||||
fn new_issue_20757_context(auth_header string) Issue20757Context {
|
||||
mut header := http.new_header_from_map({
|
||||
http.CommonHeader.origin: issue_20757_allowed_origin
|
||||
})
|
||||
if auth_header != '' {
|
||||
header.add(.authorization, auth_header)
|
||||
}
|
||||
return Issue20757Context{
|
||||
Context: Context{
|
||||
req: http.Request{
|
||||
method: .get
|
||||
url: issue_20757_route
|
||||
header: header
|
||||
}
|
||||
query: {}
|
||||
form: {}
|
||||
files: {}
|
||||
res: http.Response{
|
||||
header: http.new_header()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_issue_20757_app() &Issue20757App {
|
||||
mut app := &Issue20757App{}
|
||||
app.use(cors[Issue20757Context](CorsOptions{
|
||||
origins: [issue_20757_allowed_origin]
|
||||
allowed_methods: [.get]
|
||||
}))
|
||||
return app
|
||||
}
|
||||
|
||||
fn test_before_request_still_runs_with_cors_middleware() {
|
||||
mut app := new_issue_20757_app()
|
||||
mut ctx := new_issue_20757_context('')
|
||||
routes := generate_routes[Issue20757App, Issue20757Context](app)!
|
||||
url := urllib.parse(issue_20757_route)!
|
||||
|
||||
handle_route[Issue20757App, Issue20757Context](mut app, mut ctx, url, '', &routes)
|
||||
|
||||
assert ctx.res.status_code == int(http.Status.bad_request)
|
||||
assert ctx.res.body == 'Authorization failed'
|
||||
assert ctx.res.header.get_custom('X-BEFORE-REQUEST')? == 'true'
|
||||
}
|
||||
|
||||
fn test_before_request_can_allow_cors_request() {
|
||||
mut app := new_issue_20757_app()
|
||||
mut ctx := new_issue_20757_context('Basic ${issue_20757_secret_key}')
|
||||
routes := generate_routes[Issue20757App, Issue20757Context](app)!
|
||||
url := urllib.parse(issue_20757_route)!
|
||||
|
||||
handle_route[Issue20757App, Issue20757Context](mut app, mut ctx, url, '', &routes)
|
||||
|
||||
assert ctx.res.status_code == int(http.Status.ok)
|
||||
assert ctx.res.body == 'authorized'
|
||||
assert ctx.res.header.get_custom('X-BEFORE-REQUEST')? == 'true'
|
||||
assert ctx.res.header.get(.access_control_allow_origin)? == issue_20757_allowed_origin
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
# Cross-Site Request Forgery (CSRF) protection
|
||||
|
||||
This module implements the [double submit cookie][owasp] technique to protect routes
|
||||
from CSRF attacks.
|
||||
|
||||
CSRF is a type of attack that occurs when a malicious program/website (and others) causes
|
||||
a user's web browser to perform an action without them knowing. A web browser automatically sends
|
||||
cookies to a website when it performs a request, including session cookies. So if a user is
|
||||
authenticated on your website the website can not distinguish a forged request by a legitimate
|
||||
request.
|
||||
|
||||
## When to not add CSRF-protection
|
||||
|
||||
If you are creating a service that is intended to be used by other servers e.g. an API,
|
||||
you probably don't want CSRF-protection. An alternative would be to send an Authorization
|
||||
token in, and only in, an HTTP-header (like JSON Web Tokens). If you do that your website
|
||||
isn't vulnerable to CSRF-attacks.
|
||||
|
||||
## Usage
|
||||
|
||||
To enable CSRF-protection for your veb app you must embed the `CsrfContext` struct
|
||||
on your `Context` struct. You must also provide configuration options
|
||||
(see [configuration & security](#configuration--security-considerations)).
|
||||
|
||||
**Example:**
|
||||
|
||||
```v
|
||||
import veb
|
||||
import veb.csrf
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
csrf.CsrfContext
|
||||
}
|
||||
```
|
||||
|
||||
Change `secret` and `allowed_hosts` in a production environment!
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
const csrf_config := csrf.CsrfConfig{
|
||||
secret: 'my-secret'
|
||||
allowed_hosts: ['*']
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
Enable CSRF protection for all routes, or a certain route(s) by using veb's middleware.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
// register the CSRF middleware and pass our configuration
|
||||
// protect a specific route
|
||||
app.route_use('/login', csrf.middleware[Context](csrf_config))
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
### Setting the token
|
||||
|
||||
For the CSRF-protection to work we have to generate an anti-CSRF token and set it
|
||||
as an hidden input field on any form that will be submitted to the route we
|
||||
want to protect.
|
||||
|
||||
**Example:**
|
||||
_main.v_
|
||||
|
||||
```v ignore
|
||||
fn (app &App) index(mut ctx) veb.Result {
|
||||
// assign the config before generating the token on non-middleware routes
|
||||
ctx.config = csrf_config
|
||||
// this function will set a cookie header and generate a CSRF token
|
||||
ctx.set_csrf_token(mut ctx)
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
@[post]
|
||||
fn (app &App) login(mut ctx, password string) veb.Result {
|
||||
// implement your own password validation here
|
||||
if password == 'password' {
|
||||
return ctx.text('You are logged in!')
|
||||
} else {
|
||||
return ctx.text('Invalid password!')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_templates/index.html_
|
||||
|
||||
```html
|
||||
<h1>Log in</h1>
|
||||
<form method="POST" action="/login">
|
||||
@{ctx.csrf_token_input()}
|
||||
<label for="password">Password:</label>
|
||||
<input type="text" name="password" id="password" />
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
If we run the app with `v run main.v` and navigate to `http://localhost:8080/`
|
||||
we will see the login form and we can login using the password "password".
|
||||
|
||||
If we remove the hidden input, by removing the line `@{ctx.csrf_token_input()}`
|
||||
from our html code we will see an error message indicating that the CSRF token
|
||||
is not set or invalid! By default the CSRF module sends an HTTP-403 response when
|
||||
a token is invalid, if you want to send a custom response see the
|
||||
[advanced usage](#advanced-usage) section.
|
||||
|
||||
> **Note:**
|
||||
> Please read the security and configuration section! If you configure
|
||||
> the CSRF module in an unsafe way, the protection will be useless.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
If you want more control over what routes are protected or what action you want to
|
||||
do when a CSRF-token is invalid, you can call `csrf.protect` yourself whenever you want
|
||||
to protect a route against CSRF attacks. This function returns `false` if the current CSRF token
|
||||
and cookie combination is not valid.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
@[post]
|
||||
fn (app &App) login(mut ctx, password string) veb.Result {
|
||||
if csrf.protect(mut ctx, csrf_config) == false {
|
||||
// CSRF verification failed!
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Obtaining the anti-CSRF token
|
||||
|
||||
When `set_csrf_token` is called the token is stored in the `csrf_token` field. You access
|
||||
this field directly to use it in an input field, or call `csrf_token_input`.
|
||||
If the handler is not covered by `csrf.middleware`, assign `ctx.config = csrf_config`
|
||||
first so the generated cookie and token use the same configuration.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
fn (app &App) index(mut ctx) veb.Result {
|
||||
ctx.config = csrf_config
|
||||
token := ctx.set_csrf_token(mut ctx)
|
||||
}
|
||||
```
|
||||
|
||||
### Clearing the anti-CSRF token
|
||||
|
||||
If you want to remove the anti-CSRF token and the cookie header you can call `clear_csrf_token`
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
ctx.clear_csrf_token()
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
This module implements the [double submit cookie][owasp] technique: a random token
|
||||
is generated, the CSRF-token. The hmac of this token and the secret key is stored in a cookie.
|
||||
|
||||
When a request is made, the CSRF-token should be placed inside a HTML form element.
|
||||
The CSRF-token the hmac of the CSRF-token in the formdata is compared to the cookie.
|
||||
If the values match, the request is accepted.
|
||||
|
||||
This approach has the advantage of being stateless: there is no need to store tokens on the server
|
||||
side and validate them. The token and cookie are bound cryptographically to each other so
|
||||
an attacker would need to know both values in order to make a CSRF-attack succeed. That
|
||||
is why is it important to **not leak the CSRF-token** via an url, or some other way. This is way
|
||||
by default the `HTTPOnly` flag on the cookie is set to true.
|
||||
See [client side CSRF][client-side-csrf] for more information.
|
||||
|
||||
This is a high level overview of the implementation.
|
||||
|
||||
## Configuration & Security Considerations
|
||||
|
||||
### The secret key
|
||||
|
||||
The secret key should be a random string that is not easily guessable.
|
||||
|
||||
### Sessions
|
||||
|
||||
If your app supports some kind of user sessions, it is recommended to cryptographically
|
||||
bind the CSRF-token to the users' session. You can do that by providing the name
|
||||
of the session ID cookie. If an attacker changes the session ID in the cookie, in the
|
||||
token or both the hmac will be different and the request will be rejected.
|
||||
|
||||
**Example**:
|
||||
|
||||
```v ignore
|
||||
csrf_config = csrf.CsrfConfig{
|
||||
// ...
|
||||
session_cookie: 'my_session_id_cookie_name'
|
||||
}
|
||||
```
|
||||
|
||||
### Safe Methods
|
||||
|
||||
The HTTP methods `GET`, `OPTIONS`, `HEAD` are considered
|
||||
[safe methods][mozilla-safe-methods] meaning they should not alter the state of
|
||||
an application. If a request with a "safe method" is made, the csrf protection will be skipped.
|
||||
|
||||
You can change which methods are considered safe by changing `CsrfConfig.safe_methods`.
|
||||
|
||||
### Allowed Hosts
|
||||
|
||||
By default, both the http Origin and Referer headers are checked and matched strictly
|
||||
to the values in `allowed_hosts`. That means that you need to include each subdomain.
|
||||
|
||||
If the value of `allowed_hosts` contains the wildcard: `'*'` the headers will not be checked.
|
||||
|
||||
#### Domain name matching
|
||||
|
||||
The following configuration will not allow requests made from `test.example.com`,
|
||||
only from `example.com`.
|
||||
|
||||
**Example**
|
||||
|
||||
```v ignore
|
||||
config := csrf.CsrfConfig{
|
||||
secret: '...'
|
||||
allowed_hosts: ['example.com']
|
||||
}
|
||||
```
|
||||
|
||||
#### Referer, Origin header check
|
||||
|
||||
In some cases (like if your server is behind a proxy), the Origin or Referer header will
|
||||
not be present. If that is your case you can set `check_origin_and_referer` to `false`.
|
||||
Request will now be accepted when the Origin _or_ Referer header is valid.
|
||||
|
||||
### Share csrf cookie with subdomains
|
||||
|
||||
If you need to share the CSRF-token cookie with subdomains, you can set
|
||||
`same_site` to `.same_site_lax_mode`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration options are defined in `CsrfConfig`.
|
||||
|
||||
[//]: # 'Sources'
|
||||
[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||
[client-side-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#client-side-csrf
|
||||
[mozilla-safe-methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
|
||||
@@ -0,0 +1,241 @@
|
||||
module csrf
|
||||
|
||||
import crypto.hmac
|
||||
import crypto.sha256
|
||||
import encoding.base64
|
||||
import net.http
|
||||
import net.urllib
|
||||
import rand
|
||||
import time
|
||||
import veb
|
||||
|
||||
@[params]
|
||||
pub struct CsrfConfig {
|
||||
pub:
|
||||
secret string
|
||||
// how long the random part of the csrf-token should be
|
||||
nonce_length int = 64
|
||||
// HTTP "safe" methods meaning they shouldn't alter state.
|
||||
// If a request with any of these methods is made, `protect` will always return true
|
||||
// https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
|
||||
safe_methods []http.Method = [.get, .head, .options]
|
||||
// which hosts are allowed, enforced by checking the Origin and Referer header
|
||||
// if allowed_hosts contains '*' the check will be skipped.
|
||||
// Subdomains need to be included separately: a request from `"sub.example.com"`
|
||||
// will be rejected when `allowed_host = ['example.com']`.
|
||||
allowed_hosts []string
|
||||
// if set to true both the Referer and Origin headers must match `allowed_hosts`
|
||||
// else if either one is valid the request is accepted
|
||||
check_origin_and_referer bool = true
|
||||
// the name of the csrf-token in the hidden html input
|
||||
token_name string = 'csrftoken'
|
||||
// the name of the cookie that contains the session id
|
||||
session_cookie string
|
||||
// cookie options
|
||||
cookie_name string = 'csrftoken'
|
||||
same_site http.SameSite = .same_site_strict_mode
|
||||
cookie_path string = '/'
|
||||
// how long the cookie stays valid in seconds. Default is 30 days
|
||||
max_age int = 60 * 60 * 24 * 30
|
||||
cookie_domain string
|
||||
// whether the cookie can be send only over HTTPS
|
||||
secure bool
|
||||
// enable printing verbose statements
|
||||
verbose bool
|
||||
}
|
||||
|
||||
pub struct CsrfContext {
|
||||
pub mut:
|
||||
config CsrfConfig
|
||||
exempt bool
|
||||
// the csrftoken that should be placed in an html form
|
||||
csrf_token string
|
||||
}
|
||||
|
||||
// set_token generates a new csrf_token and adds a Cookie to the response
|
||||
pub fn (mut ctx CsrfContext) set_csrf_token[T](mut user_context T) string {
|
||||
ctx.csrf_token = set_token(mut user_context, ctx.config)
|
||||
return ctx.csrf_token
|
||||
}
|
||||
|
||||
// clear the csrf token and cookie header from the context
|
||||
pub fn (ctx &CsrfContext) clear_csrf_token[T](mut user_context T) {
|
||||
user_context.set_cookie(http.Cookie{
|
||||
name: config.cookie_name
|
||||
value: ''
|
||||
max_age: 0
|
||||
})
|
||||
}
|
||||
|
||||
// csrf_token_input returns an HTML hidden input containing the csrf token
|
||||
pub fn (ctx &CsrfContext) csrf_token_input() veb.RawHtml {
|
||||
return '<input type="hidden" name="${ctx.config.token_name}" value="${ctx.csrf_token}">'
|
||||
}
|
||||
|
||||
// middleware returns a handler that you can use with veb's middleware
|
||||
pub fn middleware[T](config CsrfConfig) veb.MiddlewareOptions[T] {
|
||||
return veb.MiddlewareOptions[T]{
|
||||
after: false
|
||||
handler: fn [config] [T](mut ctx T) bool {
|
||||
ctx.config = config
|
||||
if ctx.exempt {
|
||||
return true
|
||||
} else if ctx.req.method in config.safe_methods {
|
||||
return true
|
||||
} else {
|
||||
return protect(mut ctx, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set_token returns the csrftoken and sets an encrypted cookie with the hmac of
|
||||
// `config.get_secret` and the csrftoken
|
||||
pub fn set_token(mut ctx veb.Context, config &CsrfConfig) string {
|
||||
expire_time := time.now().add_seconds(config.max_age)
|
||||
session_id := ctx.get_cookie(config.session_cookie) or { '' }
|
||||
|
||||
token := generate_token(expire_time.unix(), session_id, config.nonce_length)
|
||||
cookie := generate_cookie(expire_time.unix(), token, config.secret)
|
||||
|
||||
// the hmac key is set as a cookie and later validated with `app.token` that must
|
||||
// be in an html form
|
||||
ctx.set_cookie(http.Cookie{
|
||||
name: config.cookie_name
|
||||
value: cookie
|
||||
same_site: config.same_site
|
||||
http_only: true
|
||||
secure: config.secure
|
||||
path: config.cookie_path
|
||||
expires: expire_time
|
||||
max_age: config.max_age
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// protect returns false and sends an http 401 response when the csrf verification
|
||||
// fails. protect will always return true if the current request method is in
|
||||
// `config.safe_methods`.
|
||||
pub fn protect(mut ctx veb.Context, config &CsrfConfig) bool {
|
||||
// if the request method is a "safe" method we allow the request
|
||||
if ctx.req.method in config.safe_methods {
|
||||
return true
|
||||
}
|
||||
|
||||
// check origin and referer header
|
||||
if check_origin_and_referer(ctx, config) == false {
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
|
||||
// use the session id from the cookie, not from the csrftoken
|
||||
session_id := ctx.get_cookie(config.session_cookie) or { '' }
|
||||
|
||||
actual_token := ctx.form[config.token_name] or {
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
// retrieve timestamp and nonce from csrftoken
|
||||
data := base64.url_decode_str(actual_token).split('.')
|
||||
if config.verbose {
|
||||
eprintln('[CSRF] Token data: ${data}')
|
||||
}
|
||||
if data.len < 3 {
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
|
||||
// check the timestamp from the csrftoken against the current time
|
||||
// if an attacker would change the timestamp on the cookie, the token or both the
|
||||
// hmac would also change.
|
||||
now := time.now().unix()
|
||||
expire_timestamp := data[0].i64()
|
||||
if expire_timestamp < now {
|
||||
// token has expired
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
nonce := data.last()
|
||||
expected_token := base64.url_encode_str('${expire_timestamp}.${session_id}.${nonce}')
|
||||
|
||||
mut actual_hash := ctx.get_cookie(config.cookie_name) or {
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
// old_expire := actual_hash.all_before('.')
|
||||
// actual_hash = actual_hash.replace_once (old_expire, expire_timestamp.str())
|
||||
|
||||
// generate new hmac based on information in the http request
|
||||
expected_hash := generate_cookie(expire_timestamp, expected_token, config.secret)
|
||||
if config.verbose {
|
||||
eprintln('[CSRF] Actual Hash: ${actual_hash}')
|
||||
eprintln('[CSRF] Expected Hash: ${expected_hash}')
|
||||
}
|
||||
|
||||
// if the new hmac matches the cookie value the request is legit
|
||||
if actual_hash != expected_hash {
|
||||
if config.verbose {
|
||||
eprintln('[CSRF] The actual hash differs from the expected hash')
|
||||
}
|
||||
request_is_invalid(mut ctx)
|
||||
return false
|
||||
}
|
||||
if config.verbose {
|
||||
eprintln('[CSRF] The actual hash matches the expected hash')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// check_origin_and_referer validates the `Origin` and `Referer` headers.
|
||||
fn check_origin_and_referer(ctx veb.Context, config &CsrfConfig) bool {
|
||||
// wildcard allow all hosts NOT SAFE!
|
||||
if '*' in config.allowed_hosts {
|
||||
return true
|
||||
}
|
||||
|
||||
// only match host and match the full domain name
|
||||
// because lets say `allowed_host` = `['example.com']`.
|
||||
// Attackers shouldn't be able to bypass this check with the domain `example.com.attacker.com`
|
||||
|
||||
origin := ctx.get_header(.origin) or { return false }
|
||||
origin_url := urllib.parse(origin) or { urllib.URL{} }
|
||||
|
||||
valid_origin := origin_url.hostname() in config.allowed_hosts
|
||||
|
||||
referer := ctx.get_header(.referer) or { return false }
|
||||
referer_url := urllib.parse(referer) or { urllib.URL{} }
|
||||
|
||||
valid_referer := referer_url.hostname() in config.allowed_hosts
|
||||
|
||||
if config.check_origin_and_referer {
|
||||
return valid_origin && valid_referer
|
||||
} else {
|
||||
return valid_origin || valid_referer
|
||||
}
|
||||
}
|
||||
|
||||
// request_is_invalid sends an http 403 response
|
||||
fn request_is_invalid(mut ctx veb.Context) {
|
||||
ctx.res.set_status(.forbidden)
|
||||
ctx.text('Forbidden: Invalid or missing CSRF token')
|
||||
}
|
||||
|
||||
fn generate_token(expire_time i64, session_id string, nonce_length int) string {
|
||||
nonce := rand.string_from_set('0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz',
|
||||
nonce_length)
|
||||
token := '${expire_time}.${session_id}.${nonce}'
|
||||
|
||||
return base64.url_encode_str(token)
|
||||
}
|
||||
|
||||
// generate_cookie converts secret key based on the request context and a random
|
||||
// token into an hmac key
|
||||
fn generate_cookie(expire_time i64, token string, secret string) string {
|
||||
hash :=
|
||||
base64.url_encode(hmac.new(secret.bytes(), token.bytes(), sha256.sum, sha256.block_size))
|
||||
cookie := '${expire_time}.${hash}'
|
||||
|
||||
return cookie
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
|
||||
import time
|
||||
import net.http
|
||||
import net.html
|
||||
import os
|
||||
import veb
|
||||
import veb.csrf
|
||||
|
||||
const sport = 12385
|
||||
const localserver = '127.0.0.1:${sport}'
|
||||
const exit_after_time = 12000 // milliseconds
|
||||
|
||||
const session_id_cookie_name = 'session_id'
|
||||
const csrf_config = &csrf.CsrfConfig{
|
||||
secret: 'my-256bit-secret'
|
||||
allowed_hosts: ['*']
|
||||
session_cookie: session_id_cookie_name
|
||||
}
|
||||
|
||||
const allowed_origin = 'example.com'
|
||||
const csrf_config_origin = csrf.CsrfConfig{
|
||||
secret: 'my-256bit-secret'
|
||||
allowed_hosts: [allowed_origin]
|
||||
session_cookie: session_id_cookie_name
|
||||
}
|
||||
|
||||
// Test CSRF functions
|
||||
// =====================================
|
||||
|
||||
fn test_set_token() {
|
||||
mut ctx := veb.Context{}
|
||||
|
||||
token := csrf.set_token(mut ctx, csrf_config)
|
||||
|
||||
cookie := ctx.res.header.get(.set_cookie) or { '' }
|
||||
assert cookie.len != 0
|
||||
assert cookie.starts_with('${csrf_config.cookie_name}=')
|
||||
}
|
||||
|
||||
fn test_protect() {
|
||||
mut ctx := veb.Context{}
|
||||
|
||||
token := csrf.set_token(mut ctx, csrf_config)
|
||||
|
||||
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
|
||||
// get cookie value from "name=value;"
|
||||
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
|
||||
|
||||
ctx = veb.Context{
|
||||
form: {
|
||||
csrf_config.token_name: token
|
||||
}
|
||||
req: http.Request{
|
||||
method: .post
|
||||
}
|
||||
}
|
||||
ctx.req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
valid := csrf.protect(mut ctx, csrf_config)
|
||||
|
||||
assert valid == true
|
||||
}
|
||||
|
||||
fn test_timeout() {
|
||||
timeout := 1
|
||||
short_time_config := &csrf.CsrfConfig{
|
||||
secret: 'my-256bit-secret'
|
||||
allowed_hosts: ['*']
|
||||
session_cookie: session_id_cookie_name
|
||||
max_age: timeout
|
||||
}
|
||||
|
||||
mut ctx := veb.Context{}
|
||||
|
||||
token := csrf.set_token(mut ctx, short_time_config)
|
||||
|
||||
// after 2 seconds the cookie should expire (maxage)
|
||||
time.sleep(2 * time.second)
|
||||
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
|
||||
// get cookie value from "name=value;"
|
||||
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
|
||||
|
||||
ctx = veb.Context{
|
||||
form: {
|
||||
short_time_config.token_name: token
|
||||
}
|
||||
req: http.Request{
|
||||
method: .post
|
||||
}
|
||||
}
|
||||
ctx.req.add_cookie(name: short_time_config.cookie_name, value: cookie)
|
||||
|
||||
valid := csrf.protect(mut ctx, short_time_config)
|
||||
|
||||
assert valid == false
|
||||
}
|
||||
|
||||
fn test_valid_origin() {
|
||||
// valid because both Origin and Referer headers are present
|
||||
token, cookie := get_token_cookie('')
|
||||
|
||||
mut req := http.Request{
|
||||
method: .post
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
req.add_header(.origin, 'http://${allowed_origin}')
|
||||
req.add_header(.referer, 'http://${allowed_origin}/test')
|
||||
mut ctx := veb.Context{
|
||||
form: {
|
||||
csrf_config.token_name: token
|
||||
}
|
||||
req: req
|
||||
}
|
||||
|
||||
mut valid := csrf.protect(mut ctx, csrf_config_origin)
|
||||
assert valid == true
|
||||
}
|
||||
|
||||
fn test_invalid_origin() {
|
||||
// invalid because either the Origin, Referer or neither are present
|
||||
token, cookie := get_token_cookie('')
|
||||
|
||||
mut req := http.Request{
|
||||
method: .post
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
req.add_header(.origin, 'http://${allowed_origin}')
|
||||
mut ctx := veb.Context{
|
||||
form: {
|
||||
csrf_config.token_name: token
|
||||
}
|
||||
req: req
|
||||
}
|
||||
|
||||
mut valid := csrf.protect(mut ctx, csrf_config_origin)
|
||||
assert valid == false
|
||||
|
||||
req = http.Request{
|
||||
method: .post
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
req.add_header(.referer, 'http://${allowed_origin}/test')
|
||||
ctx = veb.Context{
|
||||
form: {
|
||||
csrf_config.token_name: token
|
||||
}
|
||||
req: req
|
||||
}
|
||||
|
||||
valid = csrf.protect(mut ctx, csrf_config_origin)
|
||||
assert valid == false
|
||||
|
||||
req = http.Request{
|
||||
method: .post
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
ctx = veb.Context{
|
||||
form: {
|
||||
csrf_config.token_name: token
|
||||
}
|
||||
req: req
|
||||
}
|
||||
|
||||
valid = csrf.protect(mut ctx, csrf_config_origin)
|
||||
assert valid == false
|
||||
}
|
||||
|
||||
// Testing App
|
||||
// ================================
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
csrf.CsrfContext
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
mut:
|
||||
started chan bool
|
||||
}
|
||||
|
||||
pub fn (mut app App) before_accept_loop() {
|
||||
app.started <- true
|
||||
}
|
||||
|
||||
fn (app &App) index(mut ctx Context) veb.Result {
|
||||
ctx.config = *csrf_config
|
||||
ctx.set_csrf_token(mut ctx)
|
||||
|
||||
return ctx.html('<form action="/auth" method="post">
|
||||
${ctx.csrf_token_input()}
|
||||
<label for="password">Your password:</label>
|
||||
<input type="text" id="password" name="password" placeholder="Your password" />
|
||||
</form>')
|
||||
}
|
||||
|
||||
@[post]
|
||||
fn (app &App) auth(mut ctx Context) veb.Result {
|
||||
return ctx.ok('authenticated')
|
||||
}
|
||||
|
||||
// App cleanup function
|
||||
// ======================================
|
||||
|
||||
pub fn (mut app App) shutdown(mut ctx Context) veb.Result {
|
||||
spawn app.exit_gracefully()
|
||||
return ctx.ok('good bye')
|
||||
}
|
||||
|
||||
fn (app &App) exit_gracefully() {
|
||||
eprintln('>> webserver: exit_gracefully')
|
||||
time.sleep(100 * time.millisecond)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
fn exit_after_timeout(mut app App, timeout_in_ms int) {
|
||||
time.sleep(timeout_in_ms * time.millisecond)
|
||||
eprintln('>> webserver: pid: ${os.getpid()}, exiting ...')
|
||||
app.exit_gracefully()
|
||||
|
||||
eprintln('App timed out!')
|
||||
assert true == false
|
||||
}
|
||||
|
||||
// Tests for the App
|
||||
// ======================================
|
||||
|
||||
fn test_run_app_in_background() {
|
||||
mut app := &App{}
|
||||
app.route_use('/auth', csrf.middleware[Context](csrf_config))
|
||||
|
||||
spawn exit_after_timeout(mut app, exit_after_time)
|
||||
spawn veb.run_at[App, Context](mut app, port: sport, family: .ip)
|
||||
_ := <-app.started
|
||||
}
|
||||
|
||||
fn test_token_input() {
|
||||
res := http.get('http://${localserver}/') or { panic(err) }
|
||||
|
||||
mut doc := html.parse(res.body)
|
||||
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
|
||||
assert inputs.len == 1
|
||||
assert csrf_config.token_name == inputs[0].attributes['name']
|
||||
}
|
||||
|
||||
fn test_token_roundtrip_from_index_form() {
|
||||
res := http.get('http://${localserver}/') or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
|
||||
cookies := res.cookies()
|
||||
assert cookies.len == 1
|
||||
|
||||
mut doc := html.parse(res.body)
|
||||
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
|
||||
assert inputs.len == 1
|
||||
|
||||
token := inputs[0].attributes['value']
|
||||
assert token.len != 0
|
||||
|
||||
mut req := http.Request{
|
||||
method: .post
|
||||
url: 'http://${localserver}/auth'
|
||||
data: http.url_encode_form_data({
|
||||
csrf_config.token_name: token
|
||||
})
|
||||
}
|
||||
for cookie in cookies {
|
||||
req.add_cookie(cookie)
|
||||
}
|
||||
|
||||
post_res := req.do() or { panic(err) }
|
||||
assert post_res.status() == .ok
|
||||
assert post_res.body == 'authenticated'
|
||||
}
|
||||
|
||||
// utility function to check whether the route at `path` is protected against csrf
|
||||
fn protect_route_util(path string) {
|
||||
mut req := http.Request{
|
||||
method: .post
|
||||
url: 'http://${localserver}/${path}'
|
||||
}
|
||||
mut res := req.do() or { panic(err) }
|
||||
assert res.status() == .forbidden
|
||||
|
||||
// A valid request with CSRF protection should have a cookie session id,
|
||||
// csrftoken in `app.form` and the hmac of that token in a cookie
|
||||
session_id := 'user_session_id'
|
||||
token, cookie := get_token_cookie(session_id)
|
||||
|
||||
header := http.new_header_from_map({
|
||||
http.CommonHeader.origin: 'http://${allowed_origin}'
|
||||
http.CommonHeader.referer: 'http://${allowed_origin}/route'
|
||||
})
|
||||
|
||||
formdata := http.url_encode_form_data({
|
||||
csrf_config.token_name: token
|
||||
})
|
||||
|
||||
// session id is altered: test if session hijacking is possible
|
||||
// if the session id the csrftoken changes so the cookie can't be validated
|
||||
mut cookies := {
|
||||
csrf_config.cookie_name: cookie
|
||||
session_id_cookie_name: 'altered'
|
||||
}
|
||||
|
||||
req = http.Request{
|
||||
method: .post
|
||||
url: 'http://${localserver}/${path}'
|
||||
data: formdata
|
||||
header: header
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
req.add_cookie(name: session_id_cookie_name, value: 'altered')
|
||||
|
||||
res = req.do() or { panic(err) }
|
||||
assert res.status() == .forbidden
|
||||
|
||||
req = http.Request{
|
||||
method: .post
|
||||
url: 'http://${localserver}/${path}'
|
||||
data: formdata
|
||||
header: header
|
||||
}
|
||||
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
|
||||
req.add_cookie(name: session_id_cookie_name, value: session_id)
|
||||
|
||||
// Everything is valid now and the request should succeed, since session_id_cookie_name will be session_id
|
||||
res = req.do() or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
}
|
||||
|
||||
fn test_protect_app() {
|
||||
protect_route_util('/auth')
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
// This test is guaranteed to be called last.
|
||||
// It sends a request to the server to shutdown.
|
||||
x := http.get('http://${localserver}/shutdown') or {
|
||||
assert err.msg() == ''
|
||||
return
|
||||
}
|
||||
assert x.status() == .ok
|
||||
assert x.body == 'good bye'
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
fn get_token_cookie(session_id string) (string, string) {
|
||||
mut ctx := veb.Context{}
|
||||
ctx.req.add_cookie(name: session_id_cookie_name, value: session_id)
|
||||
|
||||
token := csrf.set_token(mut ctx, csrf_config_origin)
|
||||
|
||||
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
|
||||
// get cookie value from "name=value;"
|
||||
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
|
||||
return token, cookie
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module veb
|
||||
|
||||
import encoding.html
|
||||
|
||||
// Do not delete.
|
||||
// Calls to this function are generated by `fn (mut g Gen) str_val(node ast.StringInterLiteral, i int, fmts []u8) {` in vlib/v/gen/c/str_intp.v,
|
||||
// for string interpolation inside veb templates.
|
||||
// TODO: move it to template render
|
||||
fn filter(s string) string {
|
||||
return html.escape(s)
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
module veb
|
||||
|
||||
import compress.gzip
|
||||
import compress.zstd
|
||||
import net.http
|
||||
|
||||
pub type MiddlewareHandler[T] = fn (mut T) bool
|
||||
|
||||
interface MiddlewareApp {
|
||||
get_handlers_for_route(route_path string) []RouteMiddleware
|
||||
get_handlers_for_route_after(route_path string) []RouteMiddleware
|
||||
get_global_handlers() []voidptr
|
||||
get_global_handlers_after() []voidptr
|
||||
}
|
||||
|
||||
struct RouteMiddleware {
|
||||
url_parts []string
|
||||
methods []http.Method
|
||||
handler voidptr
|
||||
}
|
||||
|
||||
pub struct Middleware[T] {
|
||||
mut:
|
||||
global_handlers []voidptr
|
||||
global_handlers_after []voidptr
|
||||
route_handlers []RouteMiddleware
|
||||
route_handlers_after []RouteMiddleware
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct MiddlewareOptions[T] {
|
||||
pub:
|
||||
handler fn (mut ctx T) bool @[required]
|
||||
after bool
|
||||
methods []http.Method
|
||||
}
|
||||
|
||||
// string representation of Middleware
|
||||
pub fn (m &Middleware[T]) str() string {
|
||||
return 'veb.Middleware[${T.name}]{
|
||||
global_handlers: [${m.global_handlers.len}]
|
||||
global_handlers_after: [${m.global_handlers_after.len}]
|
||||
route_handlers: [${m.route_handlers.len}]
|
||||
route_handlers_after: [${m.route_handlers_after.len}]
|
||||
}'
|
||||
}
|
||||
|
||||
// use registers a global middleware handler
|
||||
pub fn (mut m Middleware[T]) use(options MiddlewareOptions[T]) {
|
||||
if options.after {
|
||||
m.global_handlers_after << voidptr(options.handler)
|
||||
} else {
|
||||
m.global_handlers << voidptr(options.handler)
|
||||
}
|
||||
}
|
||||
|
||||
// route_use registers a middleware handler for a specific route(s)
|
||||
pub fn (mut m Middleware[T]) route_use(route string, options MiddlewareOptions[T]) {
|
||||
middleware := RouteMiddleware{
|
||||
url_parts: route.split('/').filter(it != '')
|
||||
methods: options.methods.clone()
|
||||
handler: voidptr(options.handler)
|
||||
}
|
||||
|
||||
if options.after {
|
||||
m.route_handlers_after << middleware
|
||||
} else {
|
||||
m.route_handlers << middleware
|
||||
}
|
||||
}
|
||||
|
||||
fn (m &Middleware[T]) get_handlers_for_route(route_path string) []RouteMiddleware {
|
||||
mut handlers := []RouteMiddleware{}
|
||||
route_parts := route_path.split('/').filter(it != '')
|
||||
|
||||
for handler in m.route_handlers {
|
||||
if _ := route_matches(route_parts, handler.url_parts) {
|
||||
handlers << handler
|
||||
} else if handler.url_parts.len == 0 && route_path == '/index' {
|
||||
handlers << handler
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
fn (m &Middleware[T]) get_handlers_for_route_after(route_path string) []RouteMiddleware {
|
||||
mut handlers := []RouteMiddleware{}
|
||||
route_parts := route_path.split('/').filter(it != '')
|
||||
|
||||
for handler in m.route_handlers_after {
|
||||
if _ := route_matches(route_parts, handler.url_parts) {
|
||||
handlers << handler
|
||||
} else if handler.url_parts.len == 0 && route_path == '/index' {
|
||||
handlers << handler
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
fn (m &Middleware[T]) get_global_handlers() []voidptr {
|
||||
return m.global_handlers
|
||||
}
|
||||
|
||||
fn (m &Middleware[T]) get_global_handlers_after() []voidptr {
|
||||
return m.global_handlers_after
|
||||
}
|
||||
|
||||
fn app_route_handlers[A](app &A, route_path string) []RouteMiddleware {
|
||||
$if A is $struct {
|
||||
$for field in A.fields {
|
||||
$if field.is_embed {
|
||||
$if field.name == 'Middleware' {
|
||||
return app.$(field.name).get_handlers_for_route(route_path)
|
||||
} $else $if field.typ is $struct {
|
||||
handlers := app_route_handlers(app.$(field.name), route_path)
|
||||
if handlers.len > 0 {
|
||||
return handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []RouteMiddleware{}
|
||||
}
|
||||
|
||||
fn app_route_handlers_after[A](app &A, route_path string) []RouteMiddleware {
|
||||
$if A is $struct {
|
||||
$for field in A.fields {
|
||||
$if field.is_embed {
|
||||
$if field.name == 'Middleware' {
|
||||
return app.$(field.name).get_handlers_for_route_after(route_path)
|
||||
} $else $if field.typ is $struct {
|
||||
handlers := app_route_handlers_after(app.$(field.name), route_path)
|
||||
if handlers.len > 0 {
|
||||
return handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []RouteMiddleware{}
|
||||
}
|
||||
|
||||
fn app_global_handlers[A](app &A) []voidptr {
|
||||
$if A is $struct {
|
||||
$for field in A.fields {
|
||||
$if field.is_embed {
|
||||
$if field.name == 'Middleware' {
|
||||
return app.$(field.name).get_global_handlers()
|
||||
} $else $if field.typ is $struct {
|
||||
handlers := app_global_handlers(app.$(field.name))
|
||||
if handlers.len > 0 {
|
||||
return handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []voidptr{}
|
||||
}
|
||||
|
||||
fn app_global_handlers_after[A](app &A) []voidptr {
|
||||
$if A is $struct {
|
||||
$for field in A.fields {
|
||||
$if field.is_embed {
|
||||
$if field.name == 'Middleware' {
|
||||
return app.$(field.name).get_global_handlers_after()
|
||||
} $else $if field.typ is $struct {
|
||||
handlers := app_global_handlers_after(app.$(field.name))
|
||||
if handlers.len > 0 {
|
||||
return handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []voidptr{}
|
||||
}
|
||||
|
||||
fn validate_middleware[T](mut ctx T, raw_handlers []voidptr) bool {
|
||||
for handler in raw_handlers {
|
||||
func := MiddlewareHandler[T](handler)
|
||||
if func(mut ctx) == false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn route_middleware_matches_method(route_middleware RouteMiddleware, request_method http.Method) bool {
|
||||
return route_middleware.methods.len == 0 || request_method in route_middleware.methods
|
||||
}
|
||||
|
||||
fn get_handlers_for_method(route_middlewares []RouteMiddleware, request_method http.Method) []voidptr {
|
||||
mut handlers := []voidptr{}
|
||||
for route_middleware in route_middlewares {
|
||||
if route_middleware_matches_method(route_middleware, request_method) {
|
||||
handlers << route_middleware.handler
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
// Compression encoding types for HTTP responses
|
||||
enum ContentEncoding {
|
||||
gzip
|
||||
zstd
|
||||
}
|
||||
|
||||
// send_compressed_response compresses the response body and updates the response.
|
||||
// Returns true if compression should be skipped, false if compression was applied.
|
||||
fn send_compressed_response(mut ctx Context, encoding ContentEncoding) bool {
|
||||
compressed, encoding_name := match encoding {
|
||||
.zstd {
|
||||
data := zstd.compress(ctx.res.body.bytes()) or {
|
||||
eprintln('[veb] error while compressing with zstd: ${err.msg()}')
|
||||
return true
|
||||
}
|
||||
data, 'zstd'
|
||||
}
|
||||
.gzip {
|
||||
data := gzip.compress(ctx.res.body.bytes()) or {
|
||||
eprintln('[veb] error while compressing with gzip: ${err.msg()}')
|
||||
return true
|
||||
}
|
||||
data, 'gzip'
|
||||
}
|
||||
}
|
||||
|
||||
// Set HTTP headers for compressed content
|
||||
ctx.res.header.add(.content_encoding, encoding_name)
|
||||
ctx.res.header.set(.vary, 'Accept-Encoding')
|
||||
|
||||
// Replace the response body with the compressed data and update Content-Length.
|
||||
// The normal response path will handle sending it.
|
||||
ctx.res.body = compressed.bytestr()
|
||||
ctx.res.header.set(.content_length, compressed.len.str())
|
||||
ctx.already_compressed = true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// should_skip_compression checks if compression should be skipped for this context.
|
||||
fn should_skip_compression(ctx Context) bool {
|
||||
// Skip if already compressed (optimization for static files compressed in send_file)
|
||||
if ctx.already_compressed {
|
||||
return true
|
||||
}
|
||||
// Skip compression for files in streaming mode (no takeover)
|
||||
// Files in takeover mode (small files loaded in memory) are compressed
|
||||
if ctx.return_type == .file && ctx.takeover_mode == .none {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// encode_gzip adds gzip encoding to the HTTP Response body.
|
||||
// This middleware compresses dynamic routes and static files loaded in memory (takeover mode).
|
||||
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
|
||||
// and this middleware skips them to avoid double compression (via the already_compressed flag).
|
||||
// Register this middleware as last!
|
||||
// Usage example: app.use(veb.encode_gzip[Context]())
|
||||
pub fn encode_gzip[T]() MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
after: true
|
||||
handler: fn [T](mut ctx T) bool {
|
||||
if should_skip_compression(ctx.Context) {
|
||||
return true
|
||||
}
|
||||
return send_compressed_response(mut ctx.Context, .gzip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encode_zstd adds zstd encoding to the HTTP Response body.
|
||||
// This middleware compresses dynamic routes and static files loaded in memory (takeover mode).
|
||||
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
|
||||
// and this middleware skips them to avoid double compression (via the already_compressed flag).
|
||||
// Register this middleware as last!
|
||||
// Usage example: app.route_use('/api', veb.encode_zstd[Context]())
|
||||
pub fn encode_zstd[T]() MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
after: true
|
||||
handler: fn [T](mut ctx T) bool {
|
||||
if should_skip_compression(ctx.Context) {
|
||||
return true
|
||||
}
|
||||
return send_compressed_response(mut ctx.Context, .zstd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encode_auto adds automatic content encoding (zstd or gzip) based on the client's Accept-Encoding header.
|
||||
// This middleware checks the Accept-Encoding header and compresses with zstd if supported, otherwise gzip.
|
||||
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
|
||||
// and this middleware skips them to avoid double compression (via the already_compressed flag).
|
||||
// Register this middleware as last!
|
||||
// Usage example: app.use(veb.encode_auto[Context]())
|
||||
pub fn encode_auto[T]() MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
after: true
|
||||
handler: fn [T](mut ctx T) bool {
|
||||
if should_skip_compression(ctx.Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Accept-Encoding header to determine best compression
|
||||
accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
|
||||
supports_zstd := accept_encoding.contains('zstd')
|
||||
supports_gzip := accept_encoding.contains('gzip')
|
||||
|
||||
// Try zstd first (better compression ratio), fallback to gzip
|
||||
if supports_zstd {
|
||||
return send_compressed_response(mut ctx.Context, .zstd)
|
||||
}
|
||||
if supports_gzip {
|
||||
return send_compressed_response(mut ctx.Context, .gzip)
|
||||
}
|
||||
|
||||
// No supported compression
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decode_gzip decodes the body of a gzip'ed HTTP request.
|
||||
// Register this middleware before you do anything with the request body!
|
||||
// Usage example: app.use(veb.decode_gzip[Context]())
|
||||
pub fn decode_gzip[T]() MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
handler: fn [T](mut ctx T) bool {
|
||||
if encoding := ctx.req.header.get(.content_encoding) {
|
||||
if encoding == 'gzip' {
|
||||
decompressed := gzip.decompress(ctx.req.data.bytes()) or {
|
||||
ctx.request_error('invalid gzip encoding')
|
||||
return false
|
||||
}
|
||||
ctx.req.data = decompressed.bytestr()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decode_zstd decodes the body of a zstd-compressed HTTP request.
|
||||
// Register this middleware before you do anything with the request body!
|
||||
// Usage example: app.use(veb.decode_zstd[Context]())
|
||||
pub fn decode_zstd[T]() MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
handler: fn [T](mut ctx T) bool {
|
||||
if encoding := ctx.req.header.get(.content_encoding) {
|
||||
if encoding == 'zstd' {
|
||||
decompressed := zstd.decompress(ctx.req.data.bytes()) or {
|
||||
ctx.request_error('invalid zstd encoding')
|
||||
return false
|
||||
}
|
||||
ctx.req.data = decompressed.bytestr()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const cors_safelisted_response_headers = [http.CommonHeader.cache_control, .content_language,
|
||||
.content_length, .content_type, .expires, .last_modified, .pragma].map(it.str()).join(',')
|
||||
|
||||
// CorsOptions is used to set CORS response headers.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#the_http_response_headers
|
||||
@[params]
|
||||
pub struct CorsOptions {
|
||||
pub:
|
||||
// from which origin(s) can cross-origin requests be made; `Access-Control-Allow-Origin`
|
||||
origins []string @[required]
|
||||
// indicate whether the server allows credentials, e.g. cookies, in cross-origin requests.
|
||||
// ;`Access-Control-Allow-Credentials`
|
||||
allow_credentials bool
|
||||
// allowed HTTP headers for a cross-origin request; `Access-Control-Allow-Headers`
|
||||
allowed_headers []string
|
||||
// allowed HTTP methods for a cross-origin request; `Access-Control-Allow-Methods`
|
||||
allowed_methods []http.Method
|
||||
// indicate if clients are able to access other headers than the "CORS-safelisted"
|
||||
// response headers; `Access-Control-Expose-Headers`
|
||||
expose_headers []string
|
||||
// how long the results of a preflight request can be cached, value is in seconds
|
||||
// ; `Access-Control-Max-Age`
|
||||
max_age ?int
|
||||
}
|
||||
|
||||
// set_headers adds the CORS headers on the response
|
||||
pub fn (options &CorsOptions) set_headers(mut ctx Context) {
|
||||
// A browser will reject a CORS request when the Access-Control-Allow-Origin header
|
||||
// is not present. By not setting the CORS headers when an invalid origin is supplied
|
||||
// we force the browser to reject the preflight and the actual request.
|
||||
origin := ctx.req.header.get(.origin) or { return }
|
||||
if options.origins != ['*'] && origin !in options.origins {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.set_header(.access_control_allow_origin, origin)
|
||||
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
|
||||
|
||||
// dont' set the value of `Access-Control-Allow-Credentials` to 'false', but
|
||||
// omit the header if the value is `false`
|
||||
if options.allow_credentials {
|
||||
ctx.set_header(.access_control_allow_credentials, 'true')
|
||||
}
|
||||
|
||||
if options.allowed_headers.len > 0 {
|
||||
ctx.set_header(.access_control_allow_headers, options.allowed_headers.join(','))
|
||||
} else if _ := ctx.req.header.get(.access_control_request_headers) {
|
||||
// a server must respond with `Access-Control-Allow-Headers` if
|
||||
// `Access-Control-Request-Headers` is present in a preflight request
|
||||
ctx.set_header(.access_control_allow_headers, cors_safelisted_response_headers)
|
||||
}
|
||||
|
||||
if options.allowed_methods.len > 0 {
|
||||
method_str := options.allowed_methods.str().trim('[]')
|
||||
ctx.set_header(.access_control_allow_methods, method_str)
|
||||
}
|
||||
|
||||
if options.expose_headers.len > 0 {
|
||||
ctx.set_header(.access_control_expose_headers, options.expose_headers.join(','))
|
||||
}
|
||||
|
||||
if max_age := options.max_age {
|
||||
ctx.set_header(.access_control_max_age, max_age.str())
|
||||
}
|
||||
}
|
||||
|
||||
// validate_request checks if a cross-origin request is made and verifies the CORS
|
||||
// headers. If a cross-origin request is invalid this method will send a response
|
||||
// using `ctx`.
|
||||
pub fn (options &CorsOptions) validate_request(mut ctx Context) bool {
|
||||
origin := ctx.req.header.get(.origin) or { return true }
|
||||
if options.origins != ['*'] && origin !in options.origins {
|
||||
ctx.res.set_status(.forbidden)
|
||||
ctx.text('invalid CORS origin')
|
||||
|
||||
$if veb_trace_cors ? {
|
||||
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid origin')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ctx.set_header(.access_control_allow_origin, origin)
|
||||
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
|
||||
|
||||
if options.allow_credentials {
|
||||
ctx.set_header(.access_control_allow_credentials, 'true')
|
||||
}
|
||||
|
||||
// validate request method
|
||||
if ctx.req.method !in options.allowed_methods {
|
||||
ctx.res.set_status(.method_not_allowed)
|
||||
ctx.text('${ctx.req.method} requests are not allowed')
|
||||
|
||||
$if veb_trace_cors ? {
|
||||
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid request method: ${ctx.req.method}')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if options.allowed_headers.len > 0 && options.allowed_headers != ['*'] {
|
||||
// validate request headers
|
||||
for header in ctx.req.header.keys() {
|
||||
if header !in options.allowed_headers {
|
||||
ctx.res.set_status(.forbidden)
|
||||
ctx.text('invalid Header "${header}"')
|
||||
|
||||
$if veb_trace_cors ? {
|
||||
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$if veb_trace_cors ? {
|
||||
eprintln('[veb]: received CORS request from "${origin}": HTTP ${ctx.req.method} ${ctx.req.url}')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// cors handles cross-origin requests by adding Access-Control-* headers to a
|
||||
// preflight request and validating the headers of a cross-origin request.
|
||||
// Usage example:
|
||||
// ```v
|
||||
// app.use(veb.cors[Context](veb.CorsOptions{
|
||||
// origins: ['*']
|
||||
// allowed_methods: [.get, .head, .patch, .put, .post, .delete]
|
||||
// }))
|
||||
// ```
|
||||
pub fn cors[T](options CorsOptions) MiddlewareOptions[T] {
|
||||
return MiddlewareOptions[T]{
|
||||
handler: fn [options] [T](mut ctx T) bool {
|
||||
if ctx.req.method == .options { // preflight
|
||||
options.set_headers(mut ctx.Context)
|
||||
ctx.text('ok')
|
||||
return false
|
||||
} else {
|
||||
return options.validate_request(mut ctx.Context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
module oauth
|
||||
|
||||
import net.http
|
||||
import x.json2 as json
|
||||
|
||||
pub enum TokenPostType {
|
||||
form
|
||||
json
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
pub:
|
||||
token_url string
|
||||
client_id string
|
||||
client_secret string
|
||||
token_post_type TokenPostType = .form
|
||||
redirect_uri string
|
||||
}
|
||||
|
||||
pub struct Request {
|
||||
pub:
|
||||
client_id string
|
||||
client_secret string
|
||||
code string
|
||||
state string
|
||||
}
|
||||
|
||||
pub fn (ctx &Context) get_token(code string) string {
|
||||
oauth_request := Request{
|
||||
client_id: ctx.client_id
|
||||
client_secret: ctx.client_secret
|
||||
code: code
|
||||
// state: csrf
|
||||
}
|
||||
|
||||
js := json.encode(oauth_request)
|
||||
if ctx.token_post_type == .json {
|
||||
resp := http.post_json(ctx.token_url, js) or {
|
||||
// app.info(err.msg())
|
||||
|
||||
// return app.redirect_to_index()
|
||||
return ''
|
||||
}
|
||||
println('OAUTH RESPONSE ${resp}')
|
||||
return resp.body
|
||||
} else {
|
||||
resp := http.post_form(ctx.token_url, {
|
||||
'client_id': ctx.client_id
|
||||
'client_secret': ctx.client_secret
|
||||
'code': code
|
||||
'grant_type': 'authorization_code'
|
||||
//'scope': bot
|
||||
'redirect_uri': ctx.redirect_uri
|
||||
}) or {
|
||||
// app.info(err.msg())
|
||||
|
||||
// return app.redirect_to_index()
|
||||
return ''
|
||||
}
|
||||
println('OAUTH RESPONSE ${resp}')
|
||||
return resp.body
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
module veb
|
||||
|
||||
import net.urllib
|
||||
import net.http
|
||||
|
||||
// Parsing function attributes for methods and path.
|
||||
fn parse_attrs(name string, attrs []string) !([]http.Method, string, string) {
|
||||
if attrs.len == 0 {
|
||||
return [http.Method.get], '/${name}', ''
|
||||
}
|
||||
|
||||
mut x := attrs.clone()
|
||||
mut methods := []http.Method{}
|
||||
mut path := ''
|
||||
mut host := ''
|
||||
|
||||
for i := 0; i < x.len; {
|
||||
attr := x[i]
|
||||
attru := attr.to_upper()
|
||||
m := http.method_from_str(attru)
|
||||
if attru == 'UNSAFE' {
|
||||
x.delete(i)
|
||||
continue
|
||||
}
|
||||
if attru == 'GET' || m != .get {
|
||||
methods << m
|
||||
x.delete(i)
|
||||
continue
|
||||
}
|
||||
if attr.starts_with('/') {
|
||||
if path != '' {
|
||||
return http.MultiplePathAttributesError{}
|
||||
}
|
||||
path = attr
|
||||
x.delete(i)
|
||||
continue
|
||||
}
|
||||
if attr.starts_with('host:') {
|
||||
host = attr.all_after('host:').trim_space().trim('\'"')
|
||||
x.delete(i)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if x.len > 0 {
|
||||
return http.UnexpectedExtraAttributeError{
|
||||
attributes: x
|
||||
}
|
||||
}
|
||||
if methods.len == 0 {
|
||||
methods = [http.Method.get]
|
||||
}
|
||||
if path == '' {
|
||||
path = '/${name}'
|
||||
}
|
||||
// Make host lowercase for case-insensitive comparisons
|
||||
return methods, path, host.to_lower()
|
||||
}
|
||||
|
||||
fn parse_query_from_url(url urllib.URL) map[string]string {
|
||||
mut query := map[string]string{}
|
||||
for qvalue in url.query().data {
|
||||
query[qvalue.key] = qvalue.value
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
const boundary_start = 'boundary='
|
||||
|
||||
struct FileData {
|
||||
pub:
|
||||
filename string
|
||||
content_type string
|
||||
data string
|
||||
}
|
||||
|
||||
// TODO: fix windows files? (CLRF) issues, maybe it is in the `net` module
|
||||
fn parse_form_from_request(request http.Request) !(map[string]string, map[string][]http.FileData) {
|
||||
if request.method !in [http.Method.post, .put, .patch] {
|
||||
return map[string]string{}, map[string][]http.FileData{}
|
||||
}
|
||||
ct := request.header.get(.content_type) or { '' }.split(';').map(it.trim_left(' \t'))
|
||||
if 'multipart/form-data' in ct {
|
||||
boundaries := ct.filter(it.starts_with(boundary_start))
|
||||
if boundaries.len != 1 {
|
||||
return error('detected more that one form-data boundary')
|
||||
}
|
||||
boundary := boundaries[0].all_after(boundary_start)
|
||||
if boundary.len > 0 && boundary[0] == `"` {
|
||||
// quotes are send by our http.post_multipart_form/2:
|
||||
return http.parse_multipart_form(request.data, boundary.trim('"'))
|
||||
}
|
||||
// Firefox and other browsers, do not use quotes around the boundary:
|
||||
return http.parse_multipart_form(request.data, boundary)
|
||||
}
|
||||
return http.parse_form(request.data), map[string][]http.FileData{}
|
||||
}
|
||||
|
||||
// has_route_attributes checks if a method has attributes that indicate it should be a route handler
|
||||
fn has_route_attributes(attrs []string) bool {
|
||||
if attrs.len == 0 {
|
||||
return false
|
||||
}
|
||||
for attr in attrs {
|
||||
attru := attr.to_upper()
|
||||
if attru in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] {
|
||||
return true
|
||||
}
|
||||
if attr.starts_with('/') {
|
||||
return true
|
||||
}
|
||||
if attr.starts_with('host:') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
# Request ID Middleware
|
||||
|
||||
This module implements request ID tracking functionality for V web applications.
|
||||
Request IDs are unique identifiers assigned to each HTTP request,
|
||||
which is essential for request tracing, debugging, and maintaining distributed systems.
|
||||
|
||||
## Purpose
|
||||
|
||||
Request IDs help in:
|
||||
- Tracking requests across distributed systems
|
||||
- Correlating logs from different services
|
||||
- Debugging and troubleshooting
|
||||
- Performance monitoring
|
||||
- Request chain tracing
|
||||
|
||||
## Usage
|
||||
|
||||
To enable request ID tracking in your veb app, you must embed the `RequestIdContext`
|
||||
struct in your `Context` struct.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v
|
||||
import veb
|
||||
import veb.request_id
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
request_id.RequestIdContext
|
||||
}
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Here's a simple configuration example:
|
||||
|
||||
```v
|
||||
import rand
|
||||
import veb.request_id
|
||||
|
||||
const request_id_config = request_id.Config{
|
||||
header: 'X-Request-ID'
|
||||
generator: rand.uuid_v4
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Setup
|
||||
|
||||
Enable request ID tracking for all routes or specific routes using veb's middleware
|
||||
system.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v
|
||||
import veb
|
||||
import rand
|
||||
import veb.request_id
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
request_id.RequestIdContext
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
}
|
||||
|
||||
const request_id_config = request_id.Config{
|
||||
header: 'X-Request-ID'
|
||||
generator: rand.uuid_v4
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
// Register the RequestID middleware with custom configuration
|
||||
app.use(request_id.middleware[Context](request_id_config))
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing the Request ID
|
||||
|
||||
You can access the request ID in your route handlers:
|
||||
|
||||
```v okfmt
|
||||
import veb
|
||||
import veb.request_id
|
||||
|
||||
fn (app &App) handler(mut ctx Context) veb.Result {
|
||||
// Get the current request ID
|
||||
request_id := ctx.request_id
|
||||
// Use the request ID for logging, etc.
|
||||
return ctx.text('Request ID: ${request_id}')
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `Config` struct provides several configuration options:
|
||||
|
||||
```v okfmt
|
||||
import rand
|
||||
|
||||
pub struct Config {
|
||||
pub:
|
||||
next ?fn (ctx &veb.Context) bool
|
||||
generator fn () string = rand.uuid_v4
|
||||
header string = 'X-Request-ID'
|
||||
allow_empty bool
|
||||
force bool
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options Explained
|
||||
|
||||
- `next`: Optional function to conditionally skip the middleware
|
||||
- `generator`: Function to generate unique IDs (defaults to UUID v4)
|
||||
- `header`: HTTP header name for the request ID (defaults to "X-Request-ID")
|
||||
- `allow_empty`: Whether to allow empty request IDs
|
||||
- `force`: Whether to generate a new ID even when one already exists
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom ID Generator
|
||||
|
||||
You can provide your own ID generator function:
|
||||
|
||||
```v
|
||||
import rand
|
||||
import veb.request_id
|
||||
|
||||
fn custom_id_generator() string {
|
||||
return 'custom-prefix-${rand.uuid_v4()}'
|
||||
}
|
||||
|
||||
config := request_id.Config{
|
||||
generator: custom_id_generator
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Middleware Execution
|
||||
|
||||
Use the `next` function to skip the middleware based on custom logic:
|
||||
|
||||
```v
|
||||
import veb
|
||||
import rand
|
||||
import veb.request_id
|
||||
|
||||
config := request_id.Config{
|
||||
next: fn (ctx &veb.Context) bool {
|
||||
// Skip for health check endpoints
|
||||
return ctx.req.url.starts_with('/health')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forcing New IDs
|
||||
|
||||
When you want to ensure a new ID is generated regardless of existing headers:
|
||||
|
||||
```v
|
||||
import veb.request_id
|
||||
|
||||
config := request_id.Config{
|
||||
force: true
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Consistent Headers**: Use consistent header names across your services
|
||||
2. **ID Propagation**: Forward request IDs to downstream services
|
||||
3. **Logging Integration**: Include request IDs in your logging system
|
||||
4. **ID Format**: Use a reliable ID generator (UUID v4 is recommended)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
While request IDs are not security features, consider these points:
|
||||
- Don't include sensitive information in request IDs
|
||||
- Validate request ID format if using custom generators
|
||||
- Be cautious with request ID length (recommended: 8-128 characters)
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Integration
|
||||
|
||||
```v
|
||||
module main
|
||||
|
||||
import veb
|
||||
import veb.request_id
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
request_id.RequestIdContext
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
}
|
||||
|
||||
@['/request-id'; get]
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
return ctx.text('Current request ID: ${ctx.request_id}')
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
config := request_id.Config{
|
||||
header: 'X-Request-ID'
|
||||
force: false
|
||||
allow_empty: false
|
||||
}
|
||||
app.use(request_id.middleware[Context](config))
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Generator and Conditional Execution
|
||||
|
||||
```v
|
||||
import veb
|
||||
import rand
|
||||
import veb.request_id
|
||||
|
||||
config := request_id.Config{
|
||||
generator: fn () string {
|
||||
return 'app-${rand.uuid_v4()}'
|
||||
}
|
||||
next: fn (ctx &veb.Context) bool {
|
||||
return ctx.req.url.starts_with('/public')
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
module request_id
|
||||
|
||||
import veb
|
||||
import rand
|
||||
|
||||
@[params]
|
||||
pub struct Config {
|
||||
pub:
|
||||
// Next defines a function to skip this middleware when returned true.
|
||||
next ?fn (ctx &veb.Context) bool
|
||||
// Generator defines a function to generate the unique identifier.
|
||||
generator fn () string = rand.uuid_v4
|
||||
// Header is the header key where to get/set the unique request ID.
|
||||
header string = 'X-Request-ID'
|
||||
// Allow empty sets whether to allow empty request IDs
|
||||
allow_empty bool
|
||||
// Force determines whether to always generate a new ID even if one exists
|
||||
force bool
|
||||
}
|
||||
|
||||
@[noinit]
|
||||
pub struct RequestIdContext {
|
||||
pub mut:
|
||||
request_id_config Config
|
||||
request_id_exempt bool
|
||||
request_id string
|
||||
}
|
||||
|
||||
// get_request_id returns the current request ID
|
||||
pub fn (ctx &RequestIdContext) get_request_id() string {
|
||||
return ctx.request_id
|
||||
}
|
||||
|
||||
// middleware returns a handler that you can use with veb's middleware
|
||||
pub fn middleware[T](config Config) veb.MiddlewareOptions[T] {
|
||||
return veb.MiddlewareOptions[T]{
|
||||
after: false
|
||||
handler: fn [config] [T](mut ctx T) bool {
|
||||
if ctx.request_id_exempt {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't execute middleware if Next returns true.
|
||||
if next := config.next {
|
||||
if next(ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing ID from request
|
||||
mut rid := if !config.force {
|
||||
ctx.get_custom_header(config.header) or { '' }
|
||||
} else {
|
||||
''
|
||||
}
|
||||
|
||||
// Generate new ID if needed
|
||||
if rid == '' || config.force {
|
||||
rid = config.generator()
|
||||
}
|
||||
|
||||
// Set ID to response header if we have one
|
||||
if rid != '' {
|
||||
ctx.set_custom_header(config.header, rid)
|
||||
ctx.request_id = rid
|
||||
}
|
||||
|
||||
// Store config
|
||||
ctx.request_id_config = config
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
module request_id
|
||||
|
||||
import veb
|
||||
|
||||
// Test context that includes our RequestIdContext
|
||||
struct TestContext {
|
||||
veb.Context
|
||||
RequestIdContext
|
||||
}
|
||||
|
||||
fn test_config_default() {
|
||||
default_config := Config{}
|
||||
assert default_config.header == 'X-Request-ID'
|
||||
assert default_config.allow_empty == false
|
||||
assert default_config.force == false
|
||||
assert default_config.next == none
|
||||
assert default_config.generator != unsafe { nil }
|
||||
}
|
||||
|
||||
fn test_middleware_handler() {
|
||||
cfg := Config{
|
||||
header: 'Test-Request-ID'
|
||||
generator: fn () string {
|
||||
return 'test-123'
|
||||
}
|
||||
}
|
||||
|
||||
// Create middleware handler
|
||||
handler := middleware[TestContext](cfg).handler
|
||||
|
||||
// Create test context
|
||||
mut ctx := TestContext{}
|
||||
|
||||
// Test handler execution
|
||||
result := handler(mut ctx)
|
||||
assert result == true
|
||||
|
||||
// Verify request ID was set
|
||||
assert ctx.request_id == 'test-123'
|
||||
}
|
||||
|
||||
fn test_middleware_next_function() {
|
||||
mut ctx := TestContext{}
|
||||
|
||||
// Test with next function that returns true
|
||||
skip_cfg := Config{
|
||||
next: fn (ctx &veb.Context) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
skip_handler := middleware[TestContext](skip_cfg).handler
|
||||
|
||||
result := skip_handler(mut ctx)
|
||||
assert result == true
|
||||
assert ctx.request_id == ''
|
||||
|
||||
// Test with next function that returns false
|
||||
continue_cfg := Config{
|
||||
next: fn (ctx &veb.Context) bool {
|
||||
return false
|
||||
}
|
||||
generator: fn () string {
|
||||
return 'test-123'
|
||||
}
|
||||
}
|
||||
continue_handler := middleware[TestContext](continue_cfg).handler
|
||||
|
||||
result2 := continue_handler(mut ctx)
|
||||
assert result2 == true
|
||||
assert ctx.request_id == 'test-123'
|
||||
}
|
||||
|
||||
fn test_middleware_force_option() {
|
||||
mut ctx := TestContext{}
|
||||
ctx.request_id = 'existing-id'
|
||||
|
||||
force_cfg := Config{
|
||||
force: true
|
||||
generator: fn () string {
|
||||
return 'forced-id'
|
||||
}
|
||||
}
|
||||
force_handler := middleware[TestContext](force_cfg).handler
|
||||
|
||||
result := force_handler(mut ctx)
|
||||
assert result == true
|
||||
assert ctx.request_id == 'forced-id'
|
||||
}
|
||||
|
||||
fn test_middleware_exempt() {
|
||||
mut ctx := TestContext{
|
||||
request_id_exempt: true
|
||||
}
|
||||
|
||||
handler := middleware[TestContext](Config{}).handler
|
||||
|
||||
result := handler(mut ctx)
|
||||
assert result == true
|
||||
assert ctx.request_id == ''
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
module veb
|
||||
|
||||
struct RoutePair {
|
||||
url string
|
||||
route string
|
||||
}
|
||||
|
||||
fn (rp RoutePair) test() ?[]string {
|
||||
url := rp.url.split('/').filter(it != '')
|
||||
route := rp.route.split('/').filter(it != '')
|
||||
return route_matches(url, route)
|
||||
}
|
||||
|
||||
fn (rp RoutePair) test_match() {
|
||||
rp.test() or { panic('should match: ${rp}') }
|
||||
}
|
||||
|
||||
fn (rp RoutePair) test_no_match() {
|
||||
rp.test() or { return }
|
||||
panic('should not match: ${rp}')
|
||||
}
|
||||
|
||||
fn (rp RoutePair) test_param(expected []string) {
|
||||
res := rp.test() or { panic('should match: ${rp}') }
|
||||
assert res == expected
|
||||
}
|
||||
|
||||
fn test_route_no_match() {
|
||||
tests := [
|
||||
RoutePair{
|
||||
url: '/a'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/b'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/b/'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/c/b'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/c/b/'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/b/c/d'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/'
|
||||
},
|
||||
]
|
||||
for test in tests {
|
||||
test.test_no_match()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_route_exact_match() {
|
||||
tests := [
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a/b/c/'
|
||||
route: '/a/b/c'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/a'
|
||||
route: '/a'
|
||||
},
|
||||
RoutePair{
|
||||
url: '/'
|
||||
route: '/'
|
||||
},
|
||||
]
|
||||
for test in tests {
|
||||
test.test_match()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_route_params_match() {
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/:a/b/c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/a/:b/c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/a/b/:c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/:a/b/:c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/b/c'
|
||||
route: '/:a/b/c'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three'
|
||||
route: '/:a/b/c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three'
|
||||
route: '/:a/:b/c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three'
|
||||
route: '/:a/b/:c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/1/2/3/4'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/1/2'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_no_match()
|
||||
}
|
||||
|
||||
fn test_route_params() {
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/:a/b/c'
|
||||
}.test_param(['a'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/b/c'
|
||||
route: '/:a/b/c'
|
||||
}.test_param(['one'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/c'
|
||||
route: '/:a/:b/c'
|
||||
}.test_param(['one', 'two'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three'
|
||||
route: '/:a/:b/:c'
|
||||
}.test_param(['one', 'two', 'three'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/b/three'
|
||||
route: '/:a/b/:c'
|
||||
}.test_param(['one', 'three'])
|
||||
}
|
||||
|
||||
fn test_route_params_array_match() {
|
||||
// array can only be used on the last word (TODO: add parsing / tests to ensure this)
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/a/b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d'
|
||||
route: '/a/b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d/e'
|
||||
route: '/a/b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/b/c/d/e'
|
||||
route: '/:a/b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/c/d/e'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three/four/five'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_no_match()
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_no_match()
|
||||
}
|
||||
|
||||
fn test_route_params_array() {
|
||||
RoutePair{
|
||||
url: '/a/b/c'
|
||||
route: '/a/b/:c...'
|
||||
}.test_param(['c'])
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d'
|
||||
route: '/a/b/:c...'
|
||||
}.test_param(['c/d'])
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d/'
|
||||
route: '/a/b/:c...'
|
||||
}.test_param(['c/d'])
|
||||
|
||||
RoutePair{
|
||||
url: '/a/b/c/d/e'
|
||||
route: '/a/b/:c...'
|
||||
}.test_param(['c/d/e'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/b/c/d/e'
|
||||
route: '/:a/b/:c...'
|
||||
}.test_param(['one', 'c/d/e'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/c/d/e'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_param(['one', 'two', 'c/d/e'])
|
||||
|
||||
RoutePair{
|
||||
url: '/one/two/three/d/e'
|
||||
route: '/:a/:b/:c...'
|
||||
}.test_param(['one', 'two', 'three/d/e'])
|
||||
}
|
||||
|
||||
fn test_route_index_path() {
|
||||
RoutePair{
|
||||
url: '/'
|
||||
route: '/:path...'
|
||||
}.test_param(['/'])
|
||||
|
||||
RoutePair{
|
||||
url: '/foo/bar'
|
||||
route: '/:path...'
|
||||
}.test_param(['/foo/bar'])
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
module veb
|
||||
|
||||
fn C.sendfile(in_fd i32, out_fd i32, offset i32, count i32, voidptr offsetp, voidptr hdr, flags i32) i32
|
||||
|
||||
fn sendfile(out_fd int, in_fd int, nr_bytes int) int {
|
||||
// out_fd must be a stream socket descriptor.
|
||||
r := C.sendfile(in_fd, out_fd, 0, nr_bytes, unsafe { nil }, unsafe { nil }, 0)
|
||||
if r == 0 {
|
||||
return nr_bytes
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module veb
|
||||
|
||||
#include <sys/sendfile.h>
|
||||
|
||||
fn C.sendfile(out_fd i32, in_fd i32, offset voidptr, count i32) i32
|
||||
|
||||
fn sendfile(out_fd int, in_fd int, nr_bytes int) int {
|
||||
// always pass nil as offset, so the file offset will be used and updated.
|
||||
return C.sendfile(out_fd, in_fd, 0, nr_bytes)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
module veb
|
||||
|
||||
import time
|
||||
|
||||
// ShutdownParams configures how graceful shutdown waits for in-flight requests.
|
||||
@[params]
|
||||
pub struct ShutdownParams {
|
||||
pub:
|
||||
timeout time.Duration = time.infinite
|
||||
retry_period_ms int = 10
|
||||
}
|
||||
|
||||
// WaitTillRunningParams configures how long the server waits to report that it is serving.
|
||||
@[params]
|
||||
pub struct WaitTillRunningParams {
|
||||
pub:
|
||||
max_retries int = 100
|
||||
retry_period_ms int = 10
|
||||
}
|
||||
|
||||
interface HasInitServer {
|
||||
mut:
|
||||
init_server(server &Server)
|
||||
}
|
||||
|
||||
fn maybe_init_server[A](mut global_app A, server &Server) {
|
||||
$if A is HasInitServer {
|
||||
global_app.init_server(server)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
module veb
|
||||
|
||||
import fasthttp
|
||||
|
||||
@[heap]
|
||||
pub struct Server {
|
||||
handle fasthttp.ServerHandle
|
||||
lifecycle_control bool
|
||||
}
|
||||
|
||||
fn new_server_with_lifecycle(handle fasthttp.ServerHandle) &Server {
|
||||
return &Server{
|
||||
handle: handle
|
||||
lifecycle_control: true
|
||||
}
|
||||
}
|
||||
|
||||
fn new_server_without_lifecycle() &Server {
|
||||
return &Server{}
|
||||
}
|
||||
|
||||
fn (s &Server) ensure_lifecycle_control() ! {
|
||||
if !s.lifecycle_control {
|
||||
return error('veb server lifecycle control is not available with SSL')
|
||||
}
|
||||
}
|
||||
|
||||
// wait_till_running waits until the server starts accepting requests.
|
||||
pub fn (s &Server) wait_till_running(params WaitTillRunningParams) !int {
|
||||
s.ensure_lifecycle_control()!
|
||||
return s.handle.wait_till_running(fasthttp.WaitTillRunningParams{
|
||||
max_retries: params.max_retries
|
||||
retry_period_ms: params.retry_period_ms
|
||||
})!
|
||||
}
|
||||
|
||||
// shutdown gracefully stops accepting new requests and waits for in-flight requests to finish.
|
||||
pub fn (s &Server) shutdown(params ShutdownParams) ! {
|
||||
s.ensure_lifecycle_control()!
|
||||
s.handle.shutdown(fasthttp.ShutdownParams{
|
||||
timeout: params.timeout
|
||||
retry_period_ms: params.retry_period_ms
|
||||
})!
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Server Sent Events
|
||||
|
||||
This module implements the server side of `Server Sent Events`, SSE.
|
||||
See [mozilla SSE][mozilla_sse]
|
||||
as well as [whatwg][whatwg html spec]
|
||||
for detailed description of the protocol, and a simple web browser client example.
|
||||
|
||||
## Usage
|
||||
|
||||
With SSE we want to keep the connection open, so we are able to
|
||||
keep sending events to the client. But if we hold the connection open indefinitely
|
||||
veb isn't able to process any other requests.
|
||||
|
||||
We can let veb know that it can continue processing other requests and that we will
|
||||
handle the connection ourself by calling `ctx.takeover_conn()` and returning an empty result
|
||||
with `veb.no_result()`. veb will not close the connection and we can handle
|
||||
the connection in a separate thread.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
import veb.sse
|
||||
|
||||
// endpoint handler for SSE connections
|
||||
fn (app &App) sse(mut ctx Context) veb.Result {
|
||||
// let veb know that the connection should not be closed
|
||||
ctx.takeover_conn()
|
||||
// handle the connection in a new thread
|
||||
spawn handle_sse_conn(mut ctx)
|
||||
// we will send a custom response ourself, so we can safely return an empty result
|
||||
return veb.no_result()
|
||||
}
|
||||
|
||||
fn handle_sse_conn(mut ctx Context) {
|
||||
// pass veb.Context
|
||||
mut sse_conn := sse.start_connection(mut ctx.Context)
|
||||
|
||||
// send a message every second 3 times
|
||||
for _ in 0.. 3 {
|
||||
time.sleep(time.second)
|
||||
sse_conn.send_message(data: 'ping') or { break }
|
||||
}
|
||||
// close the SSE connection
|
||||
sse_conn.close()
|
||||
}
|
||||
```
|
||||
|
||||
Javascript code:
|
||||
|
||||
```js
|
||||
const eventSource = new EventSource('/sse');
|
||||
|
||||
eventSource.addEventListener('message', (event) => {
|
||||
console.log('received message:', event.data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('close', () => {
|
||||
console.log('closing the connection');
|
||||
// prevent browser from reconnecting
|
||||
eventSource.close();
|
||||
});
|
||||
```
|
||||
|
||||
[mozilla_sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
|
||||
[whatwg html spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
|
||||
@@ -0,0 +1,92 @@
|
||||
module sse
|
||||
|
||||
import veb
|
||||
import net
|
||||
import strings
|
||||
|
||||
// This module implements the server side of `Server Sent Events`.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
||||
// as well as https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
|
||||
// for detailed description of the protocol, and a simple web browser client example.
|
||||
//
|
||||
// > Event stream format
|
||||
// > The event stream is a simple stream of text data which must be encoded using UTF-8.
|
||||
// > Messages in the event stream are separated by a pair of newline characters.
|
||||
// > A colon as the first character of a line is in essence a comment, and is ignored.
|
||||
// > Note: The comment line can be used to prevent connections from timing out;
|
||||
// > a server can send a comment periodically to keep the connection alive.
|
||||
// >
|
||||
// > Each message consists of one or more lines of text listing the fields for that message.
|
||||
// > Each field is represented by the field name, followed by a colon, followed by the text
|
||||
// > data for that field's value.
|
||||
|
||||
@[params]
|
||||
pub struct SSEMessage {
|
||||
pub mut:
|
||||
id string
|
||||
event string
|
||||
data string
|
||||
retry int
|
||||
}
|
||||
|
||||
@[heap]
|
||||
pub struct SSEConnection {
|
||||
pub mut:
|
||||
conn &net.TcpConn @[required]
|
||||
}
|
||||
|
||||
// start an SSE connection
|
||||
pub fn start_connection(mut ctx veb.Context) &SSEConnection {
|
||||
if ctx.conn == unsafe { nil } {
|
||||
eprintln('[veb.sse] WARNING: SSE requires a direct TCP connection (ctx.conn) which is not available. Use `ctx.takeover_conn()` before starting an SSE connection.')
|
||||
return &SSEConnection{
|
||||
conn: unsafe { nil }
|
||||
}
|
||||
}
|
||||
// Build and send HTTP response headers directly.
|
||||
// SSE responses must NOT include Content-Length since data is streamed.
|
||||
mut sb := strings.new_builder(256)
|
||||
sb.write_string('HTTP/1.1 200 OK\r\n')
|
||||
sb.write_string('Content-Type: text/event-stream\r\n')
|
||||
sb.write_string('Connection: keep-alive\r\n')
|
||||
sb.write_string('Cache-Control: no-cache\r\n')
|
||||
sb.write_string('Server: veb\r\n')
|
||||
sb.write_string('\r\n')
|
||||
ctx.conn.write(sb) or {}
|
||||
|
||||
return &SSEConnection{
|
||||
conn: ctx.conn
|
||||
}
|
||||
}
|
||||
|
||||
// send_message sends a single message to the http client that listens for SSE.
|
||||
// It does not close the connection, so you can use it many times in a loop.
|
||||
pub fn (mut sse SSEConnection) send_message(message SSEMessage) ! {
|
||||
if sse.conn == unsafe { nil } {
|
||||
return error('SSE connection is not available (no TCP connection)')
|
||||
}
|
||||
mut sb := strings.new_builder(512)
|
||||
if message.id != '' {
|
||||
sb.write_string('id: ${message.id}\n')
|
||||
}
|
||||
if message.event != '' {
|
||||
sb.write_string('event: ${message.event}\n')
|
||||
}
|
||||
if message.data != '' {
|
||||
sb.write_string('data: ${message.data}\n')
|
||||
}
|
||||
if message.retry != 0 {
|
||||
sb.write_string('retry: ${message.retry}\n')
|
||||
}
|
||||
sb.write_string('\n')
|
||||
sse.conn.write(sb)!
|
||||
}
|
||||
|
||||
// send a 'close' event and close the tcp connection.
|
||||
pub fn (mut sse SSEConnection) close() {
|
||||
if sse.conn == unsafe { nil } {
|
||||
return
|
||||
}
|
||||
sse.send_message(event: 'close', data: 'Closing the connection', retry: -1) or {}
|
||||
sse.conn.close() or {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// vtest retry: 3
|
||||
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
|
||||
import veb
|
||||
import veb.sse
|
||||
import time
|
||||
import net.http
|
||||
|
||||
const port = 13008
|
||||
const localserver = 'http://127.0.0.1:${port}'
|
||||
const exit_after = time.second * 10
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
mut:
|
||||
started chan bool
|
||||
}
|
||||
|
||||
pub fn (mut app App) before_accept_loop() {
|
||||
app.started <- true
|
||||
}
|
||||
|
||||
fn (app &App) sse(mut ctx Context) veb.Result {
|
||||
ctx.takeover_conn()
|
||||
spawn handle_sse_conn(mut ctx)
|
||||
return veb.no_result()
|
||||
}
|
||||
|
||||
fn handle_sse_conn(mut ctx Context) {
|
||||
mut sse_conn := sse.start_connection(mut ctx.Context)
|
||||
|
||||
for _ in 0 .. 3 {
|
||||
time.sleep(time.second)
|
||||
sse_conn.send_message(data: 'ping') or { break }
|
||||
}
|
||||
sse_conn.close()
|
||||
}
|
||||
|
||||
fn testsuite_begin() {
|
||||
mut app := &App{}
|
||||
spawn fn () {
|
||||
time.sleep(exit_after)
|
||||
assert true == false, 'timeout reached!'
|
||||
exit(1)
|
||||
}()
|
||||
|
||||
spawn veb.run_at[App, Context](mut app, port: port, family: .ip)
|
||||
// app startup time
|
||||
_ := <-app.started
|
||||
}
|
||||
|
||||
fn test_sse() ! {
|
||||
mut x := http.get('${localserver}/sse')!
|
||||
|
||||
connection := x.header.get(.connection) or {
|
||||
assert true == false, 'Header Connection should be set!'
|
||||
panic('missing header')
|
||||
}
|
||||
cache_control := x.header.get(.cache_control) or {
|
||||
assert true == false, 'Header Cache-Control should be set!'
|
||||
panic('missing header')
|
||||
}
|
||||
content_type := x.header.get(.content_type) or {
|
||||
assert true == false, 'Header Content-Type should be set!'
|
||||
panic('missing header')
|
||||
}
|
||||
assert connection == 'keep-alive'
|
||||
assert cache_control == 'no-cache'
|
||||
assert content_type == 'text/event-stream'
|
||||
|
||||
eprintln(x.body)
|
||||
assert x.body == 'data: ping\n\ndata: ping\n\ndata: ping\n\nevent: close\ndata: Closing the connection\nretry: -1\n\n'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
module veb
|
||||
|
||||
import net
|
||||
import time
|
||||
|
||||
@[params]
|
||||
pub struct SSLConnectConfig {
|
||||
pub:
|
||||
verify string
|
||||
cert string
|
||||
cert_key string
|
||||
validate bool
|
||||
in_memory_verification bool
|
||||
read_timeout time.Duration
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct RunParams {
|
||||
pub:
|
||||
// use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1
|
||||
family net.AddrFamily = .ip6
|
||||
host string
|
||||
port int = default_port
|
||||
nr_workers int = 1
|
||||
show_startup_message bool = true
|
||||
timeout_in_seconds int = 30
|
||||
max_request_buffer_size int = 8192
|
||||
benchmark_page_generation bool // for the "page rendered in X ms"
|
||||
ssl_config SSLConnectConfig
|
||||
}
|
||||
|
||||
fn run_at_with_ssl[A, X](mut _global_app A, _params RunParams) ! {
|
||||
return error('veb HTTPS server requires net.mbedtls; -d use_openssl only avoids mbedtls for HTTP apps')
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
module veb
|
||||
|
||||
import io
|
||||
import net
|
||||
import net.http
|
||||
import net.mbedtls
|
||||
import os
|
||||
import time
|
||||
|
||||
@[params]
|
||||
pub struct RunParams {
|
||||
pub:
|
||||
// use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1
|
||||
family net.AddrFamily = .ip6
|
||||
host string
|
||||
port int = default_port
|
||||
nr_workers int = 1
|
||||
show_startup_message bool = true
|
||||
timeout_in_seconds int = 30
|
||||
max_request_buffer_size int = 8192
|
||||
benchmark_page_generation bool // for the "page rendered in X ms"
|
||||
ssl_config mbedtls.SSLConnectConfig
|
||||
}
|
||||
|
||||
fn run_at_with_ssl[A, X](mut global_app A, params RunParams) ! {
|
||||
routes := generate_routes[A, X](global_app)!
|
||||
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)!
|
||||
if params.show_startup_message {
|
||||
println('[veb] Running app on https://${startup_host(params)}:${params.port}/')
|
||||
}
|
||||
flush_stdout()
|
||||
mut ssl_listener := mbedtls.new_ssl_listener(listen_addr(params), params.ssl_config)!
|
||||
defer {
|
||||
ssl_listener.shutdown() or {}
|
||||
}
|
||||
ssl_params := &SslRequestParams{
|
||||
global_app: unsafe { voidptr(&global_app) }
|
||||
controllers_sorted: controllers_sorted
|
||||
routes: &routes
|
||||
benchmark_page_generation: params.benchmark_page_generation
|
||||
max_request_buffer_size: if params.max_request_buffer_size > 0 {
|
||||
params.max_request_buffer_size
|
||||
} else {
|
||||
max_read
|
||||
}
|
||||
}
|
||||
$if A is BeforeAcceptApp {
|
||||
global_app.before_accept_loop()
|
||||
}
|
||||
for {
|
||||
mut ssl_conn := ssl_listener.accept() or {
|
||||
eprintln('[veb] accept() failed, reason: ${err}; skipping')
|
||||
continue
|
||||
}
|
||||
ssl_conn.duration = params.timeout_in_seconds * time.second
|
||||
spawn handle_ssl_connection[A, X](mut ssl_conn, ssl_params)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ssl_connection[A, X](mut ssl_conn mbedtls.SSLConn, params &SslRequestParams) {
|
||||
defer {
|
||||
ssl_conn.shutdown() or {}
|
||||
}
|
||||
mut reader := io.new_buffered_reader(
|
||||
reader: ssl_conn
|
||||
cap: params.max_request_buffer_size
|
||||
)
|
||||
defer {
|
||||
unsafe {
|
||||
reader.free()
|
||||
}
|
||||
}
|
||||
for {
|
||||
req := read_request_from_buffered_reader(mut reader) or {
|
||||
if err !is io.Eof {
|
||||
write_ssl_response(mut ssl_conn, http_400) or {}
|
||||
}
|
||||
return
|
||||
}
|
||||
completed_context := handle_ssl_request[A, X](req, params) or {
|
||||
write_ssl_response(mut ssl_conn, http_400) or {}
|
||||
return
|
||||
}
|
||||
if completed_context.takeover_mode != .none {
|
||||
eprintln('[veb] HTTPS connections do not support takeover connections yet; closing the connection after this response.')
|
||||
}
|
||||
write_ssl_context_response(mut ssl_conn, completed_context) or {
|
||||
eprintln('[veb] error sending HTTPS response: ${err}')
|
||||
return
|
||||
}
|
||||
if completed_context.takeover_mode != .none
|
||||
|| should_close_connection(completed_context.req, completed_context.res, completed_context.client_wants_to_close) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ssl_context_response(mut ssl_conn mbedtls.SSLConn, completed_context &Context) ! {
|
||||
if !completed_context.done && completed_context.return_type == .normal {
|
||||
return error('context did not send a response')
|
||||
}
|
||||
match completed_context.return_type {
|
||||
.normal {
|
||||
write_ssl_response(mut ssl_conn, completed_context.res)!
|
||||
}
|
||||
.file {
|
||||
write_ssl_response(mut ssl_conn, completed_context.res)!
|
||||
if completed_context.return_file == '' {
|
||||
return error('missing file response path')
|
||||
}
|
||||
mut file := os.open(completed_context.return_file)!
|
||||
defer {
|
||||
file.close()
|
||||
}
|
||||
mut buf := []u8{len: max_read}
|
||||
for {
|
||||
n := file.read(mut buf) or {
|
||||
if err is io.Eof {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
ssl_conn.write(buf[..n])!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ssl_response(mut ssl_conn mbedtls.SSLConn, resp http.Response) ! {
|
||||
ssl_conn.write(resp.bytes())!
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
module veb
|
||||
|
||||
import os
|
||||
|
||||
pub interface StaticApp {
|
||||
mut:
|
||||
static_files map[string]string
|
||||
static_mime_types map[string]string
|
||||
static_hosts map[string]string
|
||||
static_prefixes []string
|
||||
enable_static_gzip bool
|
||||
enable_static_zstd bool
|
||||
enable_static_compression bool
|
||||
static_compression_max_size int
|
||||
static_compression_mime_types []string
|
||||
enable_markdown_negotiation bool
|
||||
}
|
||||
|
||||
// StaticHandler provides methods to handle static files in your veb App
|
||||
pub struct StaticHandler {
|
||||
pub mut:
|
||||
static_files map[string]string
|
||||
static_mime_types map[string]string
|
||||
static_hosts map[string]string
|
||||
static_prefixes []string
|
||||
// enable_static_gzip enables gzip compression for static files.
|
||||
// Use this for gzip-only compression. For automatic zstd/gzip selection, use enable_static_compression.
|
||||
// Default: false
|
||||
enable_static_gzip bool
|
||||
// enable_static_zstd enables zstd compression for static files.
|
||||
// Use this for zstd-only compression. For automatic zstd/gzip selection, use enable_static_compression.
|
||||
// Default: false
|
||||
enable_static_zstd bool
|
||||
// enable_static_compression enables automatic compression (zstd/gzip) for static files.
|
||||
// When enabled, Veb will choose zstd over gzip when client supports both (better compression ratio).
|
||||
// For gzip-only or zstd-only compression, use enable_static_gzip or enable_static_zstd instead.
|
||||
// Default: false
|
||||
enable_static_compression bool
|
||||
// static_compression_max_size sets the maximum file size in bytes for auto-compression.
|
||||
// Files larger than this threshold will not be auto-compressed.
|
||||
// Manual `.zst`/`.gz` files are still served for allowed MIME types.
|
||||
// Default: 1MB (1024*1024 bytes). Set to 0 to disable auto-compression completely.
|
||||
// Auto-generated cache files are stored in os.cache_dir()/veb/static_compression/.
|
||||
// If that cache directory is not writable, compressed content is served from memory as fallback.
|
||||
static_compression_max_size int = 1048576
|
||||
// static_compression_mime_types limits static compression to the listed MIME types.
|
||||
// Manual `.zst`/`.gz` files and auto-generated compressed cache files are only used for matching types.
|
||||
// Leave empty to preserve the default behavior and allow compression for all static MIME types.
|
||||
static_compression_mime_types []string
|
||||
// enable_markdown_negotiation allows the client sends Accept: text/markdown, then the server will serve .md files, if any.
|
||||
// Default: false (for backward compatibility)
|
||||
enable_markdown_negotiation bool
|
||||
}
|
||||
|
||||
// scan_static_directory recursively scans `directory_path` and returns an error if
|
||||
// no valid MIME type can be found
|
||||
fn (mut sh StaticHandler) scan_static_directory(directory_path string, mount_path string, host string) ! {
|
||||
files := os.ls(directory_path) or { panic(err) }
|
||||
if files.len > 0 {
|
||||
for file in files {
|
||||
full_path := os.join_path(directory_path, file)
|
||||
if os.is_dir(full_path) {
|
||||
sh.scan_static_directory(full_path, mount_path.trim_right('/') + '/' + file, host)!
|
||||
} else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') {
|
||||
sh.host_serve_static(host, mount_path.trim_right('/') + '/' + file, full_path)!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle_static is used to mark a folder (relative to the current working folder)
|
||||
// as one that contains only static resources (css files, images etc).
|
||||
// If `root` is set the mount path for the dir will be in '/'
|
||||
// Usage:
|
||||
// ```v
|
||||
// os.chdir( os.executable() )?
|
||||
// app.handle_static('assets', true)
|
||||
// ```
|
||||
pub fn (mut sh StaticHandler) handle_static(directory_path string, root bool) !bool {
|
||||
return sh.host_handle_static('', directory_path, root)!
|
||||
}
|
||||
|
||||
// host_handle_static is used to mark a folder (relative to the current working folder)
|
||||
// as one that contains only static resources (css files, images etc).
|
||||
// If `root` is set the mount path for the dir will be in '/'
|
||||
// Usage:
|
||||
// ```v
|
||||
// os.chdir( os.executable() )?
|
||||
// app.host_handle_static('localhost', 'assets', true)
|
||||
// ```
|
||||
pub fn (mut sh StaticHandler) host_handle_static(host string, directory_path string, root bool) !bool {
|
||||
if !os.exists(directory_path) {
|
||||
return error('directory `${directory_path}` does not exist. The directory should be relative to the current working directory: ${os.getwd()}')
|
||||
}
|
||||
dir_path := directory_path.trim_space().trim_right('/')
|
||||
mut mount_path := ''
|
||||
if dir_path != '.' && os.is_dir(dir_path) && !root {
|
||||
// Mount point hygiene, "./assets" => "/assets".
|
||||
mount_path = '/' + dir_path.trim_left('.').trim('/')
|
||||
}
|
||||
sh.scan_static_directory(dir_path, mount_path, host)!
|
||||
return true
|
||||
}
|
||||
|
||||
// mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path
|
||||
// For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'),
|
||||
// and you have a file /var/share/myassets/main.css .
|
||||
// => That file will be available at URL: http://server/assets/main.css .
|
||||
pub fn (mut sh StaticHandler) mount_static_folder_at(directory_path string, mount_path string) !bool {
|
||||
return sh.host_mount_static_folder_at('', directory_path, mount_path)!
|
||||
}
|
||||
|
||||
// host_mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://host/mount_path
|
||||
// For example: suppose you have called .host_mount_static_folder_at('localhost', '/var/share/myassets', '/assets'),
|
||||
// and you have a file /var/share/myassets/main.css .
|
||||
// => That file will be available at URL: http://localhost/assets/main.css .
|
||||
pub fn (mut sh StaticHandler) host_mount_static_folder_at(host string, directory_path string, mount_path string) !bool {
|
||||
if mount_path == '' || mount_path[0] != `/` {
|
||||
return error('invalid mount path! The path should start with `/`')
|
||||
} else if !os.exists(directory_path) {
|
||||
return error('directory `${directory_path}` does not exist. The directory should be relative to the current working directory: ${os.getwd()}')
|
||||
}
|
||||
|
||||
dir_path := directory_path.trim_right('/')
|
||||
|
||||
trim_mount_path := mount_path.trim_left('/').trim_right('/')
|
||||
sh.scan_static_directory(dir_path, '/${trim_mount_path}', host)!
|
||||
return true
|
||||
}
|
||||
|
||||
// Serves a file static
|
||||
// `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type
|
||||
pub fn (mut sh StaticHandler) serve_static(url string, file_path string) ! {
|
||||
sh.host_serve_static('', url, file_path)!
|
||||
}
|
||||
|
||||
// Serves a file static
|
||||
// `url` is the access path on the site, `file_path` is the real path to the file
|
||||
// `host` is the host to serve the file from
|
||||
pub fn (mut sh StaticHandler) host_serve_static(host string, url string, file_path string) ! {
|
||||
ext := os.file_ext(file_path).to_lower()
|
||||
|
||||
// Rudimentary guard against adding files not in mime_types.
|
||||
if ext !in sh.static_mime_types && ext !in mime_types {
|
||||
return error('unknown MIME type for file extension "${ext}". You can register your MIME type in `app.static_mime_types`')
|
||||
}
|
||||
sh.static_files[url] = file_path
|
||||
sh.static_hosts[url] = host
|
||||
sh.register_static_prefix(url)
|
||||
}
|
||||
|
||||
fn static_prefix_for_url(url string) string {
|
||||
if url.len == 0 || url[0] != `/` {
|
||||
return url
|
||||
}
|
||||
mut slash_count := 0
|
||||
for i in 0 .. url.len {
|
||||
if url[i] == `/` {
|
||||
slash_count++
|
||||
if slash_count == 2 {
|
||||
return url[..i + 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
fn (mut sh StaticHandler) register_static_prefix(url string) {
|
||||
prefix := static_prefix_for_url(url)
|
||||
if prefix !in sh.static_prefixes {
|
||||
sh.static_prefixes << prefix
|
||||
}
|
||||
}
|
||||
|
||||
fn app_static_handler[A](app &A) StaticHandler {
|
||||
$if A is $struct {
|
||||
$for field in A.fields {
|
||||
$if field.is_embed {
|
||||
$if field.name == 'StaticHandler' {
|
||||
return app.$(field.name)
|
||||
} $else $if field.typ is $struct {
|
||||
return app_static_handler(app.$(field.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return StaticHandler{
|
||||
static_files: map[string]string{}
|
||||
static_mime_types: map[string]string{}
|
||||
static_hosts: map[string]string{}
|
||||
static_prefixes: []string{}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module veb
|
||||
|
||||
import os
|
||||
|
||||
const tr_map = load_tr_map()
|
||||
|
||||
pub fn raw(s string) RawHtml {
|
||||
return RawHtml(s)
|
||||
}
|
||||
|
||||
// This function is run once, on app startup. Setting the `tr_map` const.
|
||||
// m['en']['house'] == 'House'
|
||||
fn load_tr_map() map[string]map[string]string {
|
||||
// Find all translation files to figure out how many languages we have and to load the translation map
|
||||
files := os.walk_ext('translations', '.tr')
|
||||
mut res := map[string]map[string]string{}
|
||||
for tr_path in files {
|
||||
lang := fetch_lang_from_tr_path(tr_path)
|
||||
text := os.read_file(tr_path) or {
|
||||
eprintln('translation file "${tr_path}" failed to laod')
|
||||
return {}
|
||||
}
|
||||
x := text.split('-----\n')
|
||||
for s in x {
|
||||
nl_pos := s.index('\n') or { continue }
|
||||
key := s[..nl_pos]
|
||||
val := s[nl_pos + 1..]
|
||||
res[lang][key] = val
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
fn fetch_lang_from_tr_path(path string) string {
|
||||
return path.find_between(os.path_separator, '.')
|
||||
}
|
||||
|
||||
// Used by %key in templates
|
||||
pub fn tr(lang string, key string) string {
|
||||
res := tr_map[lang][key]
|
||||
if res == '' {
|
||||
eprintln('NO TRANSLATION FOR KEY "${key}"')
|
||||
return key
|
||||
}
|
||||
return RawHtml(res)
|
||||
}
|
||||
|
||||
pub fn tr_plural(lang string, key string, amount int) string {
|
||||
s := tr_map[lang][key]
|
||||
if s == '' {
|
||||
eprintln('NO TRANSLATION FOR KEY "${key}"')
|
||||
return key
|
||||
}
|
||||
if s.contains('|') {
|
||||
//-----
|
||||
// goods
|
||||
// товар|а|ов
|
||||
vals := s.split('|')
|
||||
if vals.len != 3 {
|
||||
return s
|
||||
}
|
||||
amount_str := amount.str()
|
||||
// 1, 21, 121 товар
|
||||
ending := if amount % 10 == 1 && !amount_str.ends_with('11') { // vals[0]
|
||||
''
|
||||
// 2, 3, 4, 22 товара
|
||||
} else if amount % 10 == 2 && !amount_str.ends_with('12') {
|
||||
vals[1]
|
||||
} else if amount % 10 == 3 && !amount_str.ends_with('13') {
|
||||
vals[1]
|
||||
} else if amount % 10 == 4 && !amount_str.ends_with('14') {
|
||||
vals[1]
|
||||
} else {
|
||||
// 5 товаров, 11 товаров etc
|
||||
vals[2]
|
||||
}
|
||||
return vals[0] + ending
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module veb
|
||||
|
||||
import os
|
||||
|
||||
fn test_load_files_translations() {
|
||||
os.chdir(os.dir(@FILE))!
|
||||
|
||||
translations := load_tr_map()
|
||||
|
||||
assert 'pt-br' in translations
|
||||
assert 'en' in translations
|
||||
|
||||
assert 'msg_hello' in translations['pt-br']
|
||||
assert 'msg_hello' in translations['en']
|
||||
|
||||
assert translations['pt-br']['msg_hello'] == 'Olá'
|
||||
assert translations['en']['msg_hello'] == 'Hello'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
msg_hello
|
||||
Hello
|
||||
@@ -0,0 +1,2 @@
|
||||
msg_hello
|
||||
Olá
|
||||
@@ -0,0 +1,874 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module veb
|
||||
|
||||
import io
|
||||
import net
|
||||
import net.http
|
||||
import net.urllib
|
||||
import os
|
||||
import strconv
|
||||
import strings
|
||||
import time
|
||||
|
||||
// A type which doesn't get filtered inside templates
|
||||
pub type RawHtml = string
|
||||
|
||||
// A dummy structure that returns from routes to indicate that you actually sent something to a user
|
||||
@[noinit]
|
||||
pub struct Result {}
|
||||
|
||||
// no_result does nothing, but returns `veb.Result`. Only use it when you are sure
|
||||
// a response will be send over the connection, or in combination with `Context.takeover_conn`
|
||||
pub fn no_result() Result {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
struct Route {
|
||||
methods []http.Method
|
||||
path string
|
||||
words []string
|
||||
host string
|
||||
mut:
|
||||
middlewares []RouteMiddleware
|
||||
after_middlewares []RouteMiddleware
|
||||
}
|
||||
|
||||
fn route_path_words(path string) []string {
|
||||
if path.len == 0 || path == '/' {
|
||||
return []string{}
|
||||
}
|
||||
return path.split('/').filter(it != '')
|
||||
}
|
||||
|
||||
// Generate route structs for an app
|
||||
fn generate_routes[A, X](app &A) !map[string]Route {
|
||||
// Parsing methods attributes
|
||||
mut routes := map[string]Route{}
|
||||
$for method in A.methods {
|
||||
$if method.return_type is Result {
|
||||
http_methods, route_path, host := parse_attrs(method.name, method.attrs) or {
|
||||
return error('error parsing method attributes: ${err}')
|
||||
}
|
||||
|
||||
mut route := Route{
|
||||
methods: http_methods
|
||||
path: route_path
|
||||
words: route_path_words(route_path)
|
||||
host: host
|
||||
}
|
||||
|
||||
$if A is MiddlewareApp {
|
||||
route.middlewares = app_route_handlers(app, route_path)
|
||||
route.after_middlewares = app_route_handlers_after(app, route_path)
|
||||
}
|
||||
|
||||
routes[method.name] = route
|
||||
} $else {
|
||||
// If we have route attributes, but the wrong return type, return an error
|
||||
if has_route_attributes(method.attrs) {
|
||||
return error('method `${method.name}` at `${method.location}` has route attributes but invalid return type. Handler methods must return `veb.Result`, not `!veb.Result` or other types')
|
||||
}
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// run - start a new veb server, listening to all available addresses, at the specified `port`
|
||||
pub fn run[A, X](mut global_app A, port int) {
|
||||
run_at[A, X](mut global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) }
|
||||
}
|
||||
|
||||
struct SslRequestParams {
|
||||
global_app voidptr
|
||||
controllers_sorted []&ControllerPath
|
||||
routes &map[string]Route
|
||||
benchmark_page_generation bool
|
||||
max_request_buffer_size int
|
||||
}
|
||||
|
||||
fn ssl_enabled(params RunParams) bool {
|
||||
return params.ssl_config.cert != '' || params.ssl_config.cert_key != ''
|
||||
}
|
||||
|
||||
fn server_protocol(params RunParams) string {
|
||||
if ssl_enabled(params) {
|
||||
return 'https'
|
||||
}
|
||||
return 'http'
|
||||
}
|
||||
|
||||
fn startup_host(params RunParams) string {
|
||||
if params.host == '' {
|
||||
return 'localhost'
|
||||
}
|
||||
return params.host
|
||||
}
|
||||
|
||||
fn listen_addr(params RunParams) string {
|
||||
if params.host == '' {
|
||||
return ':${params.port}'
|
||||
}
|
||||
return '${params.host}:${params.port}'
|
||||
}
|
||||
|
||||
fn read_request_from_buffered_reader(mut reader io.BufferedReader) !http.Request {
|
||||
mut req := http.parse_request_head(mut reader)!
|
||||
if transfer_encoding_is_chunked(req.header) {
|
||||
req.data = read_chunked_request_body(mut reader)!
|
||||
return req
|
||||
}
|
||||
content_length := req.header.get(.content_length) or { '0' }
|
||||
content_length_i := content_length.int()
|
||||
if content_length_i <= 0 {
|
||||
return req
|
||||
}
|
||||
mut body := []u8{len: content_length_i}
|
||||
read_exact_bytes(mut reader, mut body)!
|
||||
req.data = body.bytestr()
|
||||
return req
|
||||
}
|
||||
|
||||
fn transfer_encoding_is_chunked(header http.Header) bool {
|
||||
transfer_encoding := header.get(.transfer_encoding) or { return false }
|
||||
for word in transfer_encoding.to_lower().split(',') {
|
||||
if word.trim_space() == 'chunked' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn read_chunked_request_body(mut reader io.BufferedReader) !string {
|
||||
mut sb := strings.new_builder(1024)
|
||||
for {
|
||||
mut chunk_size_line := reader.read_line()!
|
||||
if semicolon_idx := chunk_size_line.index(';') {
|
||||
chunk_size_line = chunk_size_line[..semicolon_idx]
|
||||
}
|
||||
chunk_size_line = chunk_size_line.trim_space()
|
||||
if chunk_size_line.len == 0 {
|
||||
return error('invalid chunk size line')
|
||||
}
|
||||
chunk_size_u64 := strconv.parse_uint(chunk_size_line, 16, 64) or {
|
||||
return error('invalid chunk size line')
|
||||
}
|
||||
if chunk_size_u64 > u64(max_int) {
|
||||
return error('chunk size too large')
|
||||
}
|
||||
chunk_size := int(chunk_size_u64)
|
||||
if chunk_size == 0 {
|
||||
for {
|
||||
trailer := reader.read_line()!
|
||||
if trailer == '' {
|
||||
return sb.str()
|
||||
}
|
||||
}
|
||||
}
|
||||
mut chunk := []u8{len: chunk_size}
|
||||
read_exact_bytes(mut reader, mut chunk)!
|
||||
sb.write(chunk)!
|
||||
mut delimiter := []u8{len: 2}
|
||||
read_exact_bytes(mut reader, mut delimiter)!
|
||||
if delimiter[0] != `\r` || delimiter[1] != `\n` {
|
||||
return error('invalid chunk delimiter')
|
||||
}
|
||||
}
|
||||
return error('invalid chunked body')
|
||||
}
|
||||
|
||||
fn read_exact_bytes(mut reader io.BufferedReader, mut buf []u8) ! {
|
||||
mut offset := 0
|
||||
for offset < buf.len {
|
||||
offset += reader.read(mut buf[offset..])!
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ssl_request[A, X](req http.Request, params &SslRequestParams) ?&Context {
|
||||
mut global_app := unsafe { &A(params.global_app) }
|
||||
page_gen_start := time.ticks()
|
||||
mut url := urllib.parse(req.url) or {
|
||||
eprintln('[veb] error parsing path "${req.url}": ${err}')
|
||||
return none
|
||||
}
|
||||
query := parse_query_from_url(url)
|
||||
form, files := parse_form_from_request(req) or {
|
||||
eprintln('[veb] error parsing form: ${err.msg()}')
|
||||
return none
|
||||
}
|
||||
host_with_port := req.header.get(.host) or { '' }
|
||||
host := request_host_name(host_with_port)
|
||||
mut ctx := &Context{
|
||||
req: req
|
||||
page_gen_start: page_gen_start
|
||||
query: query
|
||||
form: form
|
||||
files: files
|
||||
}
|
||||
ctx.client_wants_to_close = request_has_connection_close(req)
|
||||
mut user_context := X{
|
||||
Context: ctx
|
||||
}
|
||||
$if A is StaticApp {
|
||||
ctx.custom_mime_types_ref = unsafe { &global_app.static_mime_types }
|
||||
if serve_if_static[X](static_handler_config(global_app.static_files,
|
||||
global_app.static_mime_types, global_app.static_hosts, global_app.static_prefixes,
|
||||
global_app.enable_static_gzip, global_app.enable_static_zstd,
|
||||
global_app.enable_static_compression, global_app.static_compression_max_size,
|
||||
global_app.static_compression_mime_types, global_app.enable_markdown_negotiation), mut
|
||||
user_context, url, host)
|
||||
{
|
||||
// Preserve the handled context on the heap before the stack-local user context goes away.
|
||||
unsafe {
|
||||
*ctx = user_context.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
$if A is ControllerInterface {
|
||||
if completed_context := handle_controllers[X](params.controllers_sorted, ctx, mut url, host) {
|
||||
return completed_context
|
||||
}
|
||||
}
|
||||
handle_route[A, X](mut global_app, mut user_context, url, host, params.routes)
|
||||
// Preserve the handled context on the heap before the stack-local user context goes away.
|
||||
unsafe {
|
||||
*ctx = user_context.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
fn request_has_connection_close(req http.Request) bool {
|
||||
conn := req.header.get(.connection) or { return false }
|
||||
return ascii_eq_ignore_case(conn, 'close')
|
||||
}
|
||||
|
||||
fn request_host_name(host_with_port string) string {
|
||||
if host_with_port.len == 0 {
|
||||
return ''
|
||||
}
|
||||
if host_with_port[0] == `[` {
|
||||
end := host_with_port.index_u8(`]`)
|
||||
if end > 0 {
|
||||
return host_with_port[1..end]
|
||||
}
|
||||
return host_with_port
|
||||
}
|
||||
colon := host_with_port.last_index_u8(`:`)
|
||||
if colon == -1 {
|
||||
return host_with_port
|
||||
}
|
||||
for i in 0 .. colon {
|
||||
if host_with_port[i] == `:` {
|
||||
return host_with_port
|
||||
}
|
||||
}
|
||||
return host_with_port[..colon]
|
||||
}
|
||||
|
||||
fn ascii_eq_ignore_case(a string, b string) bool {
|
||||
if a.len != b.len {
|
||||
return false
|
||||
}
|
||||
for i in 0 .. a.len {
|
||||
mut ca := a[i]
|
||||
if ca >= `A` && ca <= `Z` {
|
||||
ca += 32
|
||||
}
|
||||
mut cb := b[i]
|
||||
if cb >= `A` && cb <= `Z` {
|
||||
cb += 32
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn should_close_connection(req http.Request, resp http.Response, client_wants_to_close bool) bool {
|
||||
if client_wants_to_close {
|
||||
return true
|
||||
}
|
||||
if resp_conn := resp.header.get(.connection) {
|
||||
if ascii_eq_ignore_case(resp_conn, 'close') {
|
||||
return true
|
||||
}
|
||||
if ascii_eq_ignore_case(resp_conn, 'keep-alive') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req_conn := req.header.get(.connection) {
|
||||
if ascii_eq_ignore_case(req_conn, 'close') {
|
||||
return true
|
||||
}
|
||||
if ascii_eq_ignore_case(req_conn, 'keep-alive') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return req.version != .v1_1
|
||||
}
|
||||
|
||||
interface BeforeAcceptApp {
|
||||
mut:
|
||||
before_accept_loop()
|
||||
}
|
||||
|
||||
interface HasBeforeRequestOnContext {
|
||||
mut:
|
||||
before_request()
|
||||
}
|
||||
|
||||
fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string, routes &map[string]Route) {
|
||||
mut route := Route{}
|
||||
mut middleware_has_sent_response := false
|
||||
mut not_found := false
|
||||
mut variadic_route := Route{}
|
||||
mut variadic_route_words := []string{}
|
||||
mut variadic_method_name := ''
|
||||
mut variadic_method_args := []string{}
|
||||
|
||||
defer {
|
||||
// execute middleware functions after veb is done and before the response is send
|
||||
mut was_done := true
|
||||
$if A is MiddlewareApp {
|
||||
if !not_found && !middleware_has_sent_response {
|
||||
// if the middleware doesn't send an alternate response, but only changes the
|
||||
// response object we only have to check if the `done` was previously set to true
|
||||
was_done = user_context.Context.done
|
||||
// reset `done` so the middleware functions can return a different response
|
||||
// 1 time only, since the `done` guard is still present in
|
||||
// `Context.send_response_to_client`
|
||||
user_context.Context.done = false
|
||||
|
||||
// no need to check the result of `validate_middleware`, since a response has to be sent
|
||||
// anyhow. This function makes sure no further middleware is executed.
|
||||
validate_middleware[X](mut user_context, app_global_handlers_after(app))
|
||||
// skip route-specific after-middleware if global already sent a response
|
||||
if !user_context.Context.done {
|
||||
validate_middleware[X](mut user_context, get_handlers_for_method(route.after_middlewares,
|
||||
user_context.Context.req.method))
|
||||
}
|
||||
}
|
||||
}
|
||||
// send only the headers, because if the response body is too big, TcpConn code will
|
||||
// actually block, because it has to wait for the socket to become ready to write. veb
|
||||
// will handle this case.
|
||||
if !was_done && !user_context.Context.done && user_context.Context.takeover_mode == .none {
|
||||
eprintln('[veb] handler for route "${url.path}" does not send any data!')
|
||||
// send response anyway so the connection won't block
|
||||
// fast_send_resp_header(mut user_context.conn, user_context.res) or {}
|
||||
} else if user_context.Context.takeover_mode == .none {
|
||||
// fast_send_resp_header(mut user_context.conn, user_context.res) or {}
|
||||
}
|
||||
// Context.takeover_mode is set, so the user must send a response.
|
||||
}
|
||||
|
||||
is_root_path := url.path.len == 0 || url.path == '/'
|
||||
|
||||
$if veb_livereload ? {
|
||||
if url.path.starts_with('/veb_livereload/') {
|
||||
if url.path.ends_with('current') {
|
||||
user_context.handle_veb_livereload_current()
|
||||
return
|
||||
}
|
||||
if url.path.ends_with('script.js') {
|
||||
user_context.handle_veb_livereload_script()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// first execute before_request
|
||||
$if X is HasBeforeRequestOnContext {
|
||||
user_context.before_request()
|
||||
}
|
||||
// user_context.before_request()
|
||||
if user_context.Context.done {
|
||||
return
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before_request done') }
|
||||
}
|
||||
|
||||
// then execute global middleware functions
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, app_global_handlers(app)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$if A is StaticApp {
|
||||
should_check_static := !is_root_path || app.enable_markdown_negotiation
|
||||
|| app.static_files['/'] != '' || app.static_files['/index.html'] != ''
|
||||
|| app.static_files['/index.htm'] != ''
|
||||
if should_check_static {
|
||||
if serve_if_static[X](static_handler_config(app.static_files, app.static_mime_types,
|
||||
app.static_hosts, app.static_prefixes, app.enable_static_gzip,
|
||||
app.enable_static_zstd, app.enable_static_compression,
|
||||
app.static_compression_max_size, app.static_compression_mime_types,
|
||||
app.enable_markdown_negotiation), mut user_context, url, host)
|
||||
{
|
||||
// successfully served a static file
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb route static checked') }
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route match') }
|
||||
}
|
||||
|
||||
if is_root_path {
|
||||
$for method in A.methods {
|
||||
$if method.name == 'index' && method.return_type is Result {
|
||||
route = (*routes)[method.name] or {
|
||||
eprintln('[veb] parsed attributes for the `${method.name}` are not found, skipping...')
|
||||
Route{}
|
||||
}
|
||||
if user_context.Context.req.method in route.methods
|
||||
&& (route.host == '' || route.host == host)
|
||||
&& (route.path == '/' || route.path == '/index') {
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
|
||||
user_context.Context.req.method)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
can_have_data_args := user_context.Context.req.method == .post
|
||||
|| user_context.Context.req.method == .get
|
||||
if method.args.len > 1 && can_have_data_args {
|
||||
mut args := []string{cap: method.args.len + 1}
|
||||
data := if user_context.Context.req.method == .get {
|
||||
user_context.Context.query
|
||||
} else {
|
||||
user_context.Context.form
|
||||
}
|
||||
for param in method.args[1..] {
|
||||
args << data[param.name]
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context, ...args)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
} else {
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
url_words := route_path_words(url.path)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb route words parsed') }
|
||||
}
|
||||
|
||||
// Route matching and match route specific middleware as last step
|
||||
$for method in A.methods {
|
||||
$if method.return_type is Result {
|
||||
route = (*routes)[method.name] or {
|
||||
eprintln('[veb] parsed attributes for the `${method.name}` are not found, skipping...')
|
||||
Route{}
|
||||
}
|
||||
|
||||
// Skip if the HTTP request method does not match the attributes
|
||||
if user_context.Context.req.method in route.methods {
|
||||
// Used for route matching
|
||||
route_words := route.words
|
||||
|
||||
// Skip if the host does not match or is empty
|
||||
if route.host == '' || route.host == host {
|
||||
can_have_data_args := user_context.Context.req.method == .post
|
||||
|| user_context.Context.req.method == .get
|
||||
// Route immediate matches first
|
||||
// For example URL `/register` matches route `/:user`, but `fn register()`
|
||||
// should be called first.
|
||||
if !route.path.contains('/:') && url_words == route_words {
|
||||
// We found a match
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
|
||||
user_context.Context.req.method)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if method.args.len > 1 && can_have_data_args {
|
||||
// Populate method args with form or query values
|
||||
mut args := []string{cap: method.args.len + 1}
|
||||
data := if user_context.Context.req.method == .get {
|
||||
user_context.Context.query
|
||||
} else {
|
||||
user_context.Context.form
|
||||
}
|
||||
for param in method.args[1..] {
|
||||
args << data[param.name]
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context, ...args)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
} else {
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if url_words.len == 0 && route_words.len == 1 && route_words[0] == 'index'
|
||||
&& method.name == 'index' {
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
|
||||
user_context.Context.req.method)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if method.args.len > 1 && can_have_data_args {
|
||||
// Populate method args with form or query values
|
||||
mut args := []string{cap: method.args.len + 1}
|
||||
data := if user_context.Context.req.method == .get {
|
||||
user_context.Context.query
|
||||
} else {
|
||||
user_context.Context.form
|
||||
}
|
||||
for param in method.args[1..] {
|
||||
args << data[param.name]
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context, ...args)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
} else {
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if params := route_matches(url_words, route_words) {
|
||||
if route_is_variadic(route_words) {
|
||||
if should_prefer_variadic_route(route_words, variadic_route_words) {
|
||||
variadic_route = route
|
||||
variadic_route_words = route_words.clone()
|
||||
variadic_method_name = method.name
|
||||
variadic_method_args = params.clone()
|
||||
}
|
||||
} else {
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
|
||||
user_context.Context.req.method)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
method_args := params.clone()
|
||||
if method_args.len + 1 != method.args.len {
|
||||
eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the veb route `${method.attrs}` (${method_args.len})')
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context, ...method_args)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if variadic_method_name != '' {
|
||||
route = variadic_route
|
||||
$for method in A.methods {
|
||||
$if method.return_type is Result {
|
||||
if method.name == variadic_method_name {
|
||||
$if A is MiddlewareApp {
|
||||
if validate_middleware[X](mut user_context, get_handlers_for_method(variadic_route.middlewares,
|
||||
user_context.Context.req.method)) == false {
|
||||
middleware_has_sent_response = true
|
||||
return
|
||||
}
|
||||
}
|
||||
method_args := variadic_method_args.clone()
|
||||
if method_args.len + 1 != method.args.len {
|
||||
eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the veb route `${method.attrs}` (${method_args.len})')
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
|
||||
}
|
||||
app.$method(mut user_context, ...method_args)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// return 404
|
||||
user_context.not_found()
|
||||
not_found = true
|
||||
return
|
||||
}
|
||||
|
||||
fn route_is_variadic(route_words []string) bool {
|
||||
return route_words.len > 0 && route_words[route_words.len - 1].ends_with('...')
|
||||
}
|
||||
|
||||
fn should_prefer_variadic_route(candidate []string, current []string) bool {
|
||||
if current.len == 0 {
|
||||
return true
|
||||
}
|
||||
if candidate.len != current.len {
|
||||
return candidate.len > current.len
|
||||
}
|
||||
return variadic_route_static_parts(candidate) > variadic_route_static_parts(current)
|
||||
}
|
||||
|
||||
fn variadic_route_static_parts(route_words []string) int {
|
||||
mut static_parts := 0
|
||||
for route_word in route_words[..route_words.len - 1] {
|
||||
if !route_word.starts_with(':') {
|
||||
static_parts++
|
||||
}
|
||||
}
|
||||
return static_parts
|
||||
}
|
||||
|
||||
fn route_matches(url_words []string, route_words []string) ?[]string {
|
||||
// URL path should be at least as long as the route path
|
||||
// except for the catchall route (`/:path...`)
|
||||
if route_words.len == 1 && route_words[0].starts_with(':') && route_words[0].ends_with('...') {
|
||||
return ['/' + url_words.join('/')]
|
||||
}
|
||||
if url_words.len < route_words.len {
|
||||
return none
|
||||
}
|
||||
mut params := []string{cap: url_words.len}
|
||||
if url_words.len == route_words.len {
|
||||
for i in 0 .. url_words.len {
|
||||
if route_words[i].starts_with(':') {
|
||||
// We found a path parameter
|
||||
params << url_words[i]
|
||||
} else if route_words[i] != url_words[i] {
|
||||
// This url does not match the route
|
||||
return none
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// The last route can end with ... indicating an array
|
||||
if route_words.len == 0 || !route_words[route_words.len - 1].ends_with('...') {
|
||||
return none
|
||||
}
|
||||
|
||||
for i in 0 .. route_words.len - 1 {
|
||||
if route_words[i].starts_with(':') {
|
||||
// We found a path parameter
|
||||
params << url_words[i]
|
||||
} else if route_words[i] != url_words[i] {
|
||||
// This url does not match the route
|
||||
return none
|
||||
}
|
||||
}
|
||||
params << url_words[route_words.len - 1..url_words.len].join('/')
|
||||
return params
|
||||
}
|
||||
|
||||
// check if request is for a static file and serves it
|
||||
// returns true if we served a static file, false otherwise
|
||||
fn serve_if_static[X](app StaticHandler, mut user_context X, url urllib.URL, host string) bool {
|
||||
// TODO: handle url parameters properly - for now, ignore them
|
||||
mut asked_path := url.path
|
||||
static_handler := app
|
||||
if !static_handler.enable_markdown_negotiation && asked_path == '/'
|
||||
&& static_handler.static_files['/'] == ''
|
||||
&& static_handler.static_files['/index.html'] == ''
|
||||
&& static_handler.static_files['/index.htm'] == '' {
|
||||
return false
|
||||
}
|
||||
if !static_handler.enable_markdown_negotiation
|
||||
&& !static_handler.may_contain_static_path(asked_path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Content negotiation for markdown files (if enabled)
|
||||
if static_handler.enable_markdown_negotiation {
|
||||
accept_header := user_context.req.header.get(.accept) or { '' }
|
||||
if accept_header.contains('text/markdown') {
|
||||
// Try markdown variants in order of priority
|
||||
markdown_variants := [
|
||||
asked_path + '.md',
|
||||
asked_path + '.html.md',
|
||||
asked_path + '/index.html.md',
|
||||
]
|
||||
|
||||
for variant in markdown_variants {
|
||||
if static_handler.static_files[variant] != '' {
|
||||
asked_path = variant
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base_path := os.base(asked_path)
|
||||
if !base_path.contains('.') && !asked_path.ends_with('/') {
|
||||
asked_path += '/'
|
||||
}
|
||||
|
||||
if asked_path.ends_with('/') {
|
||||
// Check for markdown index first if Accept header requests it and feature is enabled
|
||||
if static_handler.enable_markdown_negotiation {
|
||||
accept_header := user_context.req.header.get(.accept) or { '' }
|
||||
if accept_header.contains('text/markdown')
|
||||
&& static_handler.static_files[asked_path + 'index.html.md'] != '' {
|
||||
asked_path += 'index.html.md'
|
||||
} else if static_handler.static_files[asked_path + 'index.html'] != '' {
|
||||
asked_path += 'index.html'
|
||||
} else if static_handler.static_files[asked_path + 'index.htm'] != '' {
|
||||
asked_path += 'index.htm'
|
||||
}
|
||||
} else if static_handler.static_files[asked_path + 'index.html'] != '' {
|
||||
asked_path += 'index.html'
|
||||
} else if static_handler.static_files[asked_path + 'index.htm'] != '' {
|
||||
asked_path += 'index.htm'
|
||||
}
|
||||
}
|
||||
static_file := static_handler.static_files[asked_path] or { return false }
|
||||
|
||||
// StaticHandler ensures that the mime type exists on either the App or in veb
|
||||
ext := os.file_ext(static_file).to_lower()
|
||||
mut mime_type := static_handler.static_mime_types[ext] or { mime_types[ext] }
|
||||
|
||||
static_host := static_handler.static_hosts[asked_path] or { '' }
|
||||
if static_file == '' || mime_type == '' {
|
||||
return false
|
||||
}
|
||||
if static_host != '' && static_host != host {
|
||||
return false
|
||||
}
|
||||
|
||||
// Configure static file compression settings
|
||||
user_context.set_static_compression_config(static_handler.enable_static_gzip,
|
||||
static_handler.enable_static_zstd, static_handler.enable_static_compression, if static_handler.static_compression_max_size >= 0 {
|
||||
static_handler.static_compression_max_size
|
||||
} else {
|
||||
1048576 // Default: 1MB
|
||||
}, static_handler.static_compression_mime_types)
|
||||
|
||||
user_context.send_file(mime_type, static_file)
|
||||
return true
|
||||
}
|
||||
|
||||
fn (sh StaticHandler) may_contain_static_path(path string) bool {
|
||||
if sh.static_prefixes.len == 0 || path == '/' {
|
||||
return true
|
||||
}
|
||||
for prefix in sh.static_prefixes {
|
||||
if prefix.ends_with('/') {
|
||||
if path.starts_with(prefix) {
|
||||
return true
|
||||
}
|
||||
} else if path == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn static_handler_config(static_files map[string]string, static_mime_types map[string]string, static_hosts map[string]string, static_prefixes []string, enable_static_gzip bool, enable_static_zstd bool, enable_static_compression bool, static_compression_max_size int, static_compression_mime_types []string, enable_markdown_negotiation bool) StaticHandler {
|
||||
return StaticHandler{
|
||||
static_files: static_files
|
||||
static_mime_types: static_mime_types
|
||||
static_hosts: static_hosts
|
||||
static_prefixes: static_prefixes
|
||||
enable_static_gzip: enable_static_gzip
|
||||
enable_static_zstd: enable_static_zstd
|
||||
enable_static_compression: enable_static_compression
|
||||
static_compression_max_size: static_compression_max_size
|
||||
static_compression_mime_types: static_compression_mime_types
|
||||
enable_markdown_negotiation: enable_markdown_negotiation
|
||||
}
|
||||
}
|
||||
|
||||
// send a string over `conn`
|
||||
fn send_string(mut conn net.TcpConn, s string) ! {
|
||||
$if trace_send_string_conn ? {
|
||||
eprintln('> send_string: conn: ${ptr_str(conn)}')
|
||||
}
|
||||
$if trace_response ? {
|
||||
eprintln('> send_string:\n${s}\n')
|
||||
}
|
||||
if voidptr(conn) == unsafe { nil } {
|
||||
return error('connection was closed before send_string')
|
||||
}
|
||||
conn.write_string(s)!
|
||||
}
|
||||
|
||||
// Set s to the form error
|
||||
pub fn (mut ctx Context) error(s string) {
|
||||
eprintln('[veb] Context.error: ${s}')
|
||||
ctx.form_error = s
|
||||
}
|
||||
|
||||
fn fast_send_resp_header(mut conn net.TcpConn, resp http.Response) ! {
|
||||
mut sb := strings.new_builder(resp.body.len + 200)
|
||||
sb.write_string('HTTP/')
|
||||
sb.write_string(resp.http_version)
|
||||
sb.write_string(' ')
|
||||
sb.write_decimal(resp.status_code)
|
||||
sb.write_string(' ')
|
||||
sb.write_string(resp.status_msg)
|
||||
sb.write_string('\r\n')
|
||||
|
||||
resp.header.render_into_sb(mut sb,
|
||||
version: resp.version()
|
||||
)
|
||||
sb.write_string('\r\n')
|
||||
send_string(mut conn, sb.str())!
|
||||
}
|
||||
|
||||
// Formats resp to a string suitable for HTTP response transmission
|
||||
// A fast version of `resp.bytestr()` used with
|
||||
// `send_string(mut ctx.conn, resp.bytestr())`
|
||||
fn fast_send_resp(mut conn net.TcpConn, resp http.Response) ! {
|
||||
fast_send_resp_header(mut conn, resp)!
|
||||
send_string(mut conn, resp.body)!
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module veb
|
||||
|
||||
import fasthttp
|
||||
import net.http
|
||||
import strconv
|
||||
import strings
|
||||
import time
|
||||
import net.urllib
|
||||
|
||||
struct RequestParams {
|
||||
global_app voidptr
|
||||
controllers_sorted []&ControllerPath
|
||||
routes &map[string]Route
|
||||
benchmark_page_generation bool
|
||||
}
|
||||
|
||||
const http_ok_response = 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n'.bytes()
|
||||
|
||||
pub fn run_at[A, X](mut global_app A, params RunParams) ! {
|
||||
run_new[A, X](mut global_app, params)!
|
||||
}
|
||||
|
||||
// run_new - start a new veb server using the parallel fasthttp backend.
|
||||
pub fn run_new[A, X](mut global_app A, params RunParams) ! {
|
||||
if params.port <= 0 || params.port > 65535 {
|
||||
return error('invalid port number `${params.port}`, it should be between 1 and 65535')
|
||||
}
|
||||
if ssl_enabled(params) {
|
||||
maybe_init_server[A](mut global_app, new_server_without_lifecycle())
|
||||
run_at_with_ssl[A, X](mut global_app, params)!
|
||||
return
|
||||
}
|
||||
|
||||
// Generate routes and controllers just like the original run() function.
|
||||
routes := generate_routes[A, X](global_app)!
|
||||
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)!
|
||||
|
||||
// Allocate params on the heap to keep it valid for the server lifetime
|
||||
request_params := &RequestParams{
|
||||
global_app: unsafe { voidptr(&global_app) }
|
||||
controllers_sorted: controllers_sorted
|
||||
routes: &routes
|
||||
benchmark_page_generation: params.benchmark_page_generation
|
||||
}
|
||||
|
||||
// Configure and run the fasthttp server
|
||||
mut server := fasthttp.new_server(fasthttp.ServerConfig{
|
||||
family: params.family
|
||||
port: params.port
|
||||
handler: parallel_request_handler[A, X]
|
||||
max_request_buffer_size: params.max_request_buffer_size
|
||||
timeout_in_seconds: params.timeout_in_seconds
|
||||
user_data: voidptr(request_params)
|
||||
}) or {
|
||||
eprintln('Failed to create server: ${err}')
|
||||
return
|
||||
}
|
||||
handle := server.handle()
|
||||
maybe_init_server[A](mut global_app, new_server_with_lifecycle(handle))
|
||||
println('[veb] Running multi-threaded app on ${server_protocol(params)}://${startup_host(params)}:${params.port}/')
|
||||
flush_stdout()
|
||||
$if A is BeforeAcceptApp {
|
||||
mut server_thread := spawn_fasthttp_server_run(mut server)
|
||||
// Wait until the listener is bound before invoking before_accept_loop,
|
||||
// so callers using `<-app.started` actually see the server ready.
|
||||
handle.wait_till_running() or {}
|
||||
global_app.before_accept_loop()
|
||||
server_thread.wait() or { panic(err) }
|
||||
} $else {
|
||||
server.run() or { panic(err) }
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_fasthttp_server_run(mut server fasthttp.Server) thread ! {
|
||||
return spawn server.run()
|
||||
}
|
||||
|
||||
fn parallel_request_handler[A, X](req fasthttp.HttpRequest) !fasthttp.HttpResponse {
|
||||
// Get parameters from user_data - copy to avoid use-after-free
|
||||
params := unsafe { *(&RequestParams(req.user_data)) }
|
||||
mut global_app := unsafe { &A(params.global_app) }
|
||||
|
||||
client_fd := req.client_conn_fd
|
||||
|
||||
head_end := if req.body.start > 0 { req.body.start } else { req.buffer.len }
|
||||
head := req.buffer[..head_end].bytestr()
|
||||
// Parse the request head into a standard `http.Request`, then copy just the body.
|
||||
mut req2 := http.parse_request_head_str(head) or {
|
||||
return fasthttp.HttpResponse{
|
||||
content: 'HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'.bytes()
|
||||
content_owned: true
|
||||
}
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb parsed http head') }
|
||||
}
|
||||
if req.body.len > 0 {
|
||||
req2.data = req.buffer[req.body.start..req.body.start + req.body.len].bytestr()
|
||||
}
|
||||
// If the request uses chunked transfer encoding, decode the chunked body
|
||||
if transfer_encoding_is_chunked(req2.header) {
|
||||
req2.data = decode_chunked_body(req2.data) or {
|
||||
return fasthttp.HttpResponse{
|
||||
content: 'HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'.bytes()
|
||||
content_owned: true
|
||||
}
|
||||
}
|
||||
}
|
||||
if invalid_resp := content_length_validation_response(req, req2) {
|
||||
return invalid_resp
|
||||
}
|
||||
// Create and populate the `veb.Context`.
|
||||
mut completed_context := handle_request_and_route[A, X](mut global_app, req2, client_fd, params)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb handled route') }
|
||||
}
|
||||
|
||||
match completed_context.takeover_mode {
|
||||
.manual {
|
||||
// The handler has taken over the connection (e.g. for SSE or WebSocket).
|
||||
// The response was already sent directly over ctx.conn.
|
||||
// Tell fasthttp to hand off the fd without closing it.
|
||||
return fasthttp.HttpResponse{
|
||||
takeover_mode: .manual
|
||||
}
|
||||
}
|
||||
.reusable {
|
||||
should_close := should_close_connection(completed_context.req, completed_context.res,
|
||||
completed_context.client_wants_to_close)
|
||||
return fasthttp.HttpResponse{
|
||||
takeover_mode: .reusable
|
||||
should_close: should_close
|
||||
}
|
||||
}
|
||||
.none {}
|
||||
}
|
||||
|
||||
should_close := should_close_connection(completed_context.req, completed_context.res,
|
||||
completed_context.client_wants_to_close)
|
||||
content := completed_context.res.bytes()
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb serialized response') }
|
||||
}
|
||||
unsafe { completed_context.res.body.free() }
|
||||
completed_context.res.body = ''
|
||||
return_type := completed_context.return_type
|
||||
return_file := completed_context.return_file
|
||||
unsafe { free(completed_context) }
|
||||
|
||||
if return_type == .file {
|
||||
return fasthttp.HttpResponse{
|
||||
content: content
|
||||
content_owned: true
|
||||
file_path: return_file
|
||||
should_close: should_close
|
||||
}
|
||||
}
|
||||
|
||||
// The fasthttp server expects a complete response buffer to be returned.
|
||||
return fasthttp.HttpResponse{
|
||||
content: content
|
||||
content_owned: true
|
||||
should_close: should_close
|
||||
}
|
||||
} // handle_request_and_route is a unified function that creates the context,
|
||||
|
||||
fn content_length_validation_response(req fasthttp.HttpRequest, parsed http.Request) ?fasthttp.HttpResponse {
|
||||
if transfer_encoding_is_chunked(parsed.header) {
|
||||
return none
|
||||
}
|
||||
content_length := parsed.header.get(.content_length) or { return none }
|
||||
expected_length := content_length.int()
|
||||
actual_length := req.body.len
|
||||
if actual_length == expected_length {
|
||||
return none
|
||||
}
|
||||
if actual_length < expected_length {
|
||||
return fasthttp.HttpResponse{
|
||||
content: http_408.bytes()
|
||||
content_owned: true
|
||||
}
|
||||
}
|
||||
return fasthttp.HttpResponse{
|
||||
content: http.new_response(
|
||||
status: .bad_request
|
||||
body: 'Mismatch of body length and Content-Length header'
|
||||
header: http.new_header(
|
||||
key: .content_type
|
||||
value: 'text/plain'
|
||||
).join(headers_close)
|
||||
).bytes()
|
||||
content_owned: true
|
||||
}
|
||||
}
|
||||
|
||||
// runs middleware, and finds the correct route for a request.
|
||||
fn handle_request_and_route[A, X](mut app A, req http.Request, _client_fd int, params RequestParams) &Context {
|
||||
// Create and populate the `veb.Context` from the request.
|
||||
mut url := urllib.parse_request_uri(req.url) or {
|
||||
// This should be rare if http.parse_request succeeded.
|
||||
mut bad_ctx := &Context{
|
||||
req: req
|
||||
}
|
||||
bad_ctx.not_found()
|
||||
return bad_ctx
|
||||
}
|
||||
query := parse_query_from_url(url)
|
||||
form, files := parse_form_from_request(req) or {
|
||||
mut bad_ctx := &Context{
|
||||
req: req
|
||||
}
|
||||
bad_ctx.request_error('Failed to parse form data: ${err.msg()}')
|
||||
return bad_ctx
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb parsed url/form') }
|
||||
}
|
||||
host_with_port := req.header.get(.host) or { '' }
|
||||
host := request_host_name(host_with_port)
|
||||
page_gen_start := if params.benchmark_page_generation { time.ticks() } else { 0 }
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb parsed host') }
|
||||
}
|
||||
mut ctx := &Context{
|
||||
req: req
|
||||
page_gen_start: page_gen_start
|
||||
client_fd: _client_fd
|
||||
client_wants_to_close: request_has_connection_close(req)
|
||||
query: query
|
||||
form: form
|
||||
files: files
|
||||
}
|
||||
mut user_context := X{
|
||||
Context: ctx
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb context initialized') }
|
||||
}
|
||||
$if A is StaticApp {
|
||||
ctx.custom_mime_types_ref = unsafe { &app.static_mime_types }
|
||||
if serve_if_static[X](static_handler_config(app.static_files, app.static_mime_types,
|
||||
app.static_hosts, app.static_prefixes, app.enable_static_gzip, app.enable_static_zstd,
|
||||
app.enable_static_compression, app.static_compression_max_size,
|
||||
app.static_compression_mime_types, app.enable_markdown_negotiation), mut user_context,
|
||||
url, host)
|
||||
{
|
||||
// Preserve the handled context on the heap before the stack-local user context goes away.
|
||||
unsafe {
|
||||
*ctx = user_context.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb pre-route static checked') }
|
||||
}
|
||||
// Match controller paths first
|
||||
$if A is ControllerInterface {
|
||||
if completed_context := handle_controllers[X](params.controllers_sorted, ctx, mut url, host) {
|
||||
return completed_context
|
||||
}
|
||||
}
|
||||
// Create a new user context and pass veb's context
|
||||
handle_route[A, X](mut app, mut user_context, url, host, params.routes)
|
||||
$if trace_prealloc ? {
|
||||
unsafe { prealloc_scope_checkpoint(c'veb route returned') }
|
||||
}
|
||||
// Preserve the handled context on the heap before the stack-local user context goes away.
|
||||
unsafe {
|
||||
*ctx = user_context.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// decode_chunked_body decodes a chunked transfer-encoded body string
|
||||
// into the raw body content.
|
||||
fn decode_chunked_body(data string) !string {
|
||||
mut sb := strings.new_builder(data.len)
|
||||
mut pos := 0
|
||||
for pos < data.len {
|
||||
// Find the end of the chunk size line
|
||||
line_end := data.index_after_('\r\n', pos)
|
||||
if line_end == -1 {
|
||||
return error('invalid chunked body: missing chunk size line ending')
|
||||
}
|
||||
chunk_size_str := data[pos..line_end].all_before(';').trim_space()
|
||||
if chunk_size_str.len == 0 {
|
||||
return error('invalid chunked body: empty chunk size')
|
||||
}
|
||||
chunk_size := int(strconv.parse_uint(chunk_size_str, 16, 64) or {
|
||||
return error('invalid chunked body: bad chunk size')
|
||||
})
|
||||
pos = line_end + 2 // skip past \r\n
|
||||
if chunk_size == 0 {
|
||||
// Terminal chunk - skip trailers
|
||||
break
|
||||
}
|
||||
if pos + chunk_size > data.len {
|
||||
return error('invalid chunked body: chunk data truncated')
|
||||
}
|
||||
sb.write_string(data[pos..pos + chunk_size])
|
||||
pos += chunk_size + 2 // skip chunk data + \r\n delimiter
|
||||
}
|
||||
return sb.str()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
module veb
|
||||
|
||||
import time
|
||||
|
||||
// Note: to use live reloading while developing, the suggested workflow is doing:
|
||||
// `v -d veb_livereload watch --keep run your_veb_server_project.v`
|
||||
// in one shell, then open the start page of your veb app in a browser.
|
||||
//
|
||||
// While developing, just open your files and edit them, then just save your
|
||||
// changes. Once you save, the watch command from above, will restart your server,
|
||||
// and your HTML pages will detect that shortly, then they will refresh themselves
|
||||
// automatically.
|
||||
|
||||
// veb_livereload_server_start records, when the veb server process started.
|
||||
// That is later used by the /script.js and /current endpoints, which are active,
|
||||
// if you have compiled your veb project with `-d veb_livereload`, to detect
|
||||
// whether the web server has been restarted.
|
||||
const veb_livereload_server_start = time.ticks().str()
|
||||
|
||||
// handle_veb_livereload_current serves a small text file, containing the
|
||||
// timestamp/ticks corresponding to when the veb server process was started
|
||||
@[if veb_livereload ?]
|
||||
fn (mut ctx Context) handle_veb_livereload_current() {
|
||||
ctx.send_response_to_client('text/plain', veb_livereload_server_start)
|
||||
}
|
||||
|
||||
// handle_veb_livereload_script serves a small dynamically generated .js file,
|
||||
// that contains code for polling the veb server, and reloading the page, if it
|
||||
// detects that the veb server is newer than the veb server, that served the
|
||||
// .js file originally.
|
||||
@[if veb_livereload ?]
|
||||
fn (mut ctx Context) handle_veb_livereload_script() {
|
||||
res := '"use strict";
|
||||
function veb_livereload_checker_fn(started_at) {
|
||||
fetch("/veb_livereload/" + started_at + "/current", { cache: "no-cache" })
|
||||
.then((response) => response.text())
|
||||
.then(function (current_at) {
|
||||
// console.log(started_at); console.log(current_at);
|
||||
if (started_at !== current_at) {
|
||||
// the app was restarted on the server:
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
const veb_livereload_checker = setInterval(veb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${veb_livereload_server_start}");
|
||||
'
|
||||
ctx.send_response_to_client('text/javascript', res)
|
||||
}
|
||||
Reference in new issue
Block a user