diff options
| author | Indrajith K L | 2022-12-03 17:00:20 +0530 | 
|---|---|---|
| committer | Indrajith K L | 2022-12-03 17:00:20 +0530 | 
| commit | f5c4671bfbad96bf346bd7e9a21fc4317b4959df (patch) | |
| tree | 2764fc62da58f2ba8da7ed341643fc359873142f /v_windows/v/old/vlib/vweb | |
| download | cli-tools-windows-f5c4671bfbad96bf346bd7e9a21fc4317b4959df.tar.gz cli-tools-windows-f5c4671bfbad96bf346bd7e9a21fc4317b4959df.tar.bz2 cli-tools-windows-f5c4671bfbad96bf346bd7e9a21fc4317b4959df.zip | |
Diffstat (limited to 'v_windows/v/old/vlib/vweb')
| -rw-r--r-- | v_windows/v/old/vlib/vweb/README.md | 141 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/assets/assets.v | 201 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/assets/assets_test.v | 179 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/request.v | 157 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/request_test.v | 138 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/route_test.v | 270 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/sse/sse.v | 77 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/tests/vweb_test.v | 298 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/tests/vweb_test_server.v | 118 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/vweb.v | 640 | ||||
| -rw-r--r-- | v_windows/v/old/vlib/vweb/vweb_app_test.v | 63 | 
11 files changed, 2282 insertions, 0 deletions
| diff --git a/v_windows/v/old/vlib/vweb/README.md b/v_windows/v/old/vlib/vweb/README.md new file mode 100644 index 0000000..850048c --- /dev/null +++ b/v_windows/v/old/vlib/vweb/README.md @@ -0,0 +1,141 @@ +# vweb - the V Web Server # + +A simple yet powerful web server with built-in routing, parameter handling, +templating, and other features. + +## Alpha level software ## + +Some features may not be complete, and there may still be bugs.  However, it is +still a very useful tool.  The [gitly](https://gitly.org/) site is based on vweb. + +## Features ## + +- **Very fast** performance of C on the web. +- **Small binary** hello world website is <100 KB. +- **Easy to deploy** just one binary file that also includes all templates. +  No need to install any dependencies. +- **Templates are precompiled** all errors are visible at compilation time, +  not at runtime. + +There is no formal documentation yet - here is a simple +[example](https://github.com/vlang/v/tree/master/examples/vweb/vweb_example.v) + +There's also the V forum, [vorum](https://github.com/vlang/vorum) + +`vorum.v` contains all GET and POST actions. + +```v ignore +pub fn (app mut App) index() { +	posts := app.find_all_posts() +	$vweb.html() +} + +// TODO ['/post/:id/:title'] +// TODO `fn (app App) post(id int)` +pub fn (app App) post() { +	id := app.get_post_id() +	post := app.retrieve_post(id) or { +		app.redirect('/') +		return +	} +	comments := app.find_comments(id) +	show_form := true +	$vweb.html() +} +``` + +`index.html` is an example of the V template language: + +```html +@for post in posts +	<div class=post> +		<a class=topic href="@post.url">@post.title</a> +		<img class=comment-img> +		<span class=nr-comments>@post.nr_comments</span> +		<span class=time>@post.time</span> +	</div> +@end +``` + +`$vweb.html()` compiles an HTML template into V during compilation, +and embeds the resulting code into the current action. + +That means that the template automatically has access to that action's entire environment. + +## Deploying vweb apps ## + +Everything, including HTML templates, is in one binary file. That's all you need to deploy. + +## Getting Started ## + +To start with vweb, you have to import the module `vweb`.  After the import, +define a struct to hold vweb.Context (and any other variables your program will +need). + +The web server can be started by calling `vweb.run(&App{}, port)`. + +**Example:** + +```v ignore +import vweb + +struct App { +    vweb.Context +} + +fn main() { +	vweb.run(&App{}, 8080) +} +``` + +### Defining endpoints ### + +To add endpoints to your web server, you have to extend the `App` struct. +For routing you can either use auto-mapping of function names or specify the path as an attribute. +The function expects a response of the type `vweb.Result`. + +**Example:** + +```v ignore +// This endpoint can be accessed via http://localhost:port/hello +fn (mut app App) hello() vweb.Result { +	return app.text('Hello') +} + +// This endpoint can be accessed via http://localhost:port/foo +["/foo"] +fn (mut app App) world() vweb.Result { +	return app.text('World') +} +``` + +To create an HTTP POST endpoint, you simply add a `[post]` attribute before the function definition. + +**Example:** + +```v ignore +[post] +fn (mut app App) world() vweb.Result { +	return app.text('World') +} +``` + +To pass a parameter to an endpoint, you simply define it inside +an attribute, e. g. `['/hello/:user]`. +After it is defined in the attribute, you have to add it as a function parameter. + +**Example:** + +```v ignore +['/hello/:user'] +fn (mut app App) hello_user(user string) vweb.Result { +	return app.text('Hello $user') +} +``` + +You have access to the raw request data such as headers +or the request body by accessing `app` (which is `vweb.Context`). +If you want to read the request body, you can do that by calling `app.req.data`. +To read the request headers, you just call `app.req.header` and access the +header you want, e.g. `app.req.header.get(.content_type)`. See `struct Header` +for all available methods (`v doc net.http Header`). diff --git a/v_windows/v/old/vlib/vweb/assets/assets.v b/v_windows/v/old/vlib/vweb/assets/assets.v new file mode 100644 index 0000000..1240e41 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/assets/assets.v @@ -0,0 +1,201 @@ +module assets + +// this module provides an AssetManager for combining +// and caching javascript & css. +import os +import time +import crypto.md5 + +const ( +	unknown_asset_type_error = 'vweb.assets: unknown asset type' +) + +struct AssetManager { +mut: +	css []Asset +	js  []Asset +pub mut: +	// when true assets will be minified +	minify bool +	// the directory to store the cached/combined files +	cache_dir string +} + +struct Asset { +	file_path     string +	last_modified time.Time +} + +// new_manager returns a new AssetManager +pub fn new_manager() &AssetManager { +	return &AssetManager{} +} + +// add_css adds a css asset +pub fn (mut am AssetManager) add_css(file string) bool { +	return am.add('css', file) +} + +// add_js adds a js asset +pub fn (mut am AssetManager) add_js(file string) bool { +	return am.add('js', file) +} + +// combine_css returns the combined css as a string when to_file is false +// when to_file is true it combines the css to disk and returns the path of the file +pub fn (am AssetManager) combine_css(to_file bool) string { +	return am.combine('css', to_file) +} + +// combine_js returns the combined js as a string when to_file is false +// when to_file is true it combines the css to disk and returns the path of the file +pub fn (am AssetManager) combine_js(to_file bool) string { +	return am.combine('js', to_file) +} + +// include_css returns the html <link> tag(s) for including the css files in a page. +// when combine is true the files are combined. +pub fn (am AssetManager) include_css(combine bool) string { +	return am.include('css', combine) +} + +// include_js returns the html <script> tag(s) for including the js files in a page. +// when combine is true the files are combined. +pub fn (am AssetManager) include_js(combine bool) string { +	return am.include('js', combine) +} + +fn (am AssetManager) combine(asset_type string, to_file bool) string { +	if am.cache_dir == '' { +		panic('vweb.assets: you must set a cache dir.') +	} +	cache_key := am.get_cache_key(asset_type) +	out_file := '$am.cache_dir/${cache_key}.$asset_type' +	mut out := '' +	// use cache +	if os.exists(out_file) { +		if to_file { +			return out_file +		} +		cached := os.read_file(out_file) or { return '' } +		return cached +	} +	// rebuild +	for asset in am.get_assets(asset_type) { +		data := os.read_file(asset.file_path) or { return '' } +		out += data +	} +	if am.minify { +		if asset_type == 'css' { +			out = minify_css(out) +		} else { +			out = minify_js(out) +		} +	} +	if !to_file { +		return out +	} +	if !os.is_dir(am.cache_dir) { +		os.mkdir(am.cache_dir) or { panic(err) } +	} +	mut file := os.create(out_file) or { panic(err) } +	file.write(out.bytes()) or { panic(err) } +	file.close() +	return out_file +} + +fn (am AssetManager) get_cache_key(asset_type string) string { +	mut files_salt := '' +	mut latest_modified := u64(0) +	for asset in am.get_assets(asset_type) { +		files_salt += asset.file_path +		if asset.last_modified.unix > latest_modified { +			latest_modified = asset.last_modified.unix +		} +	} +	hash := md5.sum(files_salt.bytes()).hex() +	return '$hash-$latest_modified' +} + +fn (am AssetManager) include(asset_type string, combine bool) string { +	assets := am.get_assets(asset_type) +	mut out := '' +	if asset_type == 'css' { +		if combine { +			file := am.combine(asset_type, true) +			return '<link rel="stylesheet" href="$file">\n' +		} +		for asset in assets { +			out += '<link rel="stylesheet" href="$asset.file_path">\n' +		} +	} +	if asset_type == 'js' { +		if combine { +			file := am.combine(asset_type, true) +			return '<script type="text/javascript" src="$file"></script>\n' +		} +		for asset in assets { +			out += '<script type="text/javascript" src="$asset.file_path"></script>\n' +		} +	} +	return out +} + +// dont return option until size limit is removed +// fn (mut am AssetManager) add(asset_type, file string) ?bool { +fn (mut am AssetManager) add(asset_type string, file string) bool { +	if !os.exists(file) { +		// return error('vweb.assets: cannot add asset $file, it does not exist') +		return false +	} +	asset := Asset{ +		file_path: file +		last_modified: time.Time{ +			unix: u64(os.file_last_mod_unix(file)) +		} +	} +	if asset_type == 'css' { +		am.css << asset +	} else if asset_type == 'js' { +		am.js << asset +	} else { +		panic('$assets.unknown_asset_type_error ($asset_type).') +	} +	return true +} + +fn (am AssetManager) exists(asset_type string, file string) bool { +	assets := am.get_assets(asset_type) +	for asset in assets { +		if asset.file_path == file { +			return true +		} +	} +	return false +} + +fn (am AssetManager) get_assets(asset_type string) []Asset { +	if asset_type != 'css' && asset_type != 'js' { +		panic('$assets.unknown_asset_type_error ($asset_type).') +	} +	assets := if asset_type == 'css' { am.css } else { am.js } +	return assets +} + +// todo: implement proper minification +pub fn minify_css(css string) string { +	mut lines := css.split('\n') +	for i, _ in lines { +		lines[i] = lines[i].trim_space() +	} +	return lines.join(' ') +} + +// todo: implement proper minification +pub fn minify_js(js string) string { +	mut lines := js.split('\n') +	for i, _ in lines { +		lines[i] = lines[i].trim_space() +	} +	return lines.join(' ') +} diff --git a/v_windows/v/old/vlib/vweb/assets/assets_test.v b/v_windows/v/old/vlib/vweb/assets/assets_test.v new file mode 100644 index 0000000..6170f3c --- /dev/null +++ b/v_windows/v/old/vlib/vweb/assets/assets_test.v @@ -0,0 +1,179 @@ +import vweb.assets +import os + +// 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) { +	if os.is_dir(dir) { +		os.rmdir_all(dir) or { panic(err) } +	} +} + +fn base_cache_dir() string { +	return os.join_path(os.temp_dir(), 'assets_test_cache') +} + +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) +	if !os.is_dir(base_cache_dir()) { +		os.mkdir_all(base_cache_dir()) or { panic(err) } +	} +	if !os.exists(path) { +		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_set_cache() { +	mut am := assets.new_manager() +	am.cache_dir = 'cache' +} + +fn test_set_minify() { +	mut am := assets.new_manager() +	am.minify = true +} + +fn test_add() { +	mut am := assets.new_manager() +	assert am.add('css', 'testx.css') == false +	assert am.add('css', get_test_file_path('test1.css')) == true +	assert am.add('js', get_test_file_path('test1.js')) == true +	// assert am.add('css', get_test_file_path('test2.js')) == false // TODO: test extension on add +} + +fn test_add_css() { +	mut am := assets.new_manager() +	assert am.add_css('testx.css') == false +	assert am.add_css(get_test_file_path('test1.css')) == true +	// assert am.add_css(get_test_file_path('test1.js')) == false // TODO: test extension on add +} + +fn test_add_js() { +	mut am := assets.new_manager() +	assert am.add_js('testx.js') == false +	assert am.add_css(get_test_file_path('test1.js')) == true +	// assert am.add_css(get_test_file_path('test1.css')) == false // TODO: test extension on add +} + +fn test_combine_css() { +	mut am := assets.new_manager() +	am.cache_dir = cache_dir('test_combine_css') +	clean_cache_dir(am.cache_dir) +	am.add_css(get_test_file_path('test1.css')) +	am.add_css(get_test_file_path('test2.css')) +	// TODO: How do I test non-minified, is there a "here doc" format that keeps formatting? +	am.minify = true +	expected := '.one { color: #336699; } .two { color: #996633; } ' +	actual := am.combine_css(false) +	assert actual == expected +	assert actual.contains(expected) +	// Test cache path doesn't change when input files and minify setting do not. +	path1 := am.combine_css(true) +	clean_cache_dir(am.cache_dir) +	path2 := am.combine_css(true) +	assert path1 == path2 +	clean_cache_dir(am.cache_dir) +} + +fn test_combine_js() { +	mut am := assets.new_manager() +	am.cache_dir = cache_dir('test_combine_js') +	clean_cache_dir(am.cache_dir) +	am.add_js(get_test_file_path('test1.js')) +	am.add_js(get_test_file_path('test2.js')) +	expected1 := '{"one": 1}' +	expected2 := '{"two": 2}' +	expected := expected1 + '\n' + expected2 + '\n' +	actual := am.combine_js(false) +	assert actual == expected +	assert actual.contains(expected) +	assert actual.contains(expected1) +	assert actual.contains(expected2) +	am.minify = true +	clean_cache_dir(am.cache_dir) +	expected3 := expected1 + ' ' + expected2 + ' ' +	actual2 := am.combine_js(false) +	assert actual2 == expected3 +	assert actual2.contains(expected3) +	// Test cache path doesn't change when input files and minify setting do not. +	path1 := am.combine_js(true) +	clean_cache_dir(am.cache_dir) +	path2 := am.combine_js(true) +	assert path1 == path2 +	clean_cache_dir(am.cache_dir) +} + +fn test_include_css() { +	mut am := assets.new_manager() +	file1 := get_test_file_path('test1.css') +	am.add_css(file1) +	expected := '<link rel="stylesheet" href="$file1">\n' +	actual := am.include_css(false) +	assert actual == expected +	assert actual.contains(expected) +	// Two lines of output. +	file2 := get_test_file_path('test2.css') +	am.add_css(file2) +	am.cache_dir = cache_dir('test_include_css') +	clean_cache_dir(am.cache_dir) +	expected2 := expected + '<link rel="stylesheet" href="$file2">\n' +	actual2 := am.include_css(false) +	assert actual2 == expected2 +	assert actual2.contains(expected2) +	// Combined output. +	clean_cache_dir(am.cache_dir) +	actual3 := am.include_css(true) +	assert actual3.contains(expected2) == false +	assert actual3.starts_with('<link rel="stylesheet" href="$am.cache_dir/') == true +	// Test cache path doesn't change when input files and minify setting do not. +	clean_cache_dir(am.cache_dir) +	actual4 := am.include_css(true) +	assert actual4 == actual3 +	clean_cache_dir(am.cache_dir) +} + +fn test_include_js() { +	mut am := assets.new_manager() +	file1 := get_test_file_path('test1.js') +	am.add_js(file1) +	expected := '<script type="text/javascript" src="$file1"></script>\n' +	actual := am.include_js(false) +	assert actual == expected +	assert actual.contains(expected) +	// Two lines of output. +	file2 := get_test_file_path('test2.js') +	am.add_js(file2) +	am.cache_dir = cache_dir('test_include_js') +	clean_cache_dir(am.cache_dir) +	expected2 := expected + '<script type="text/javascript" src="$file2"></script>\n' +	actual2 := am.include_js(false) +	assert actual2 == expected2 +	assert actual2.contains(expected2) +	// Combined output. +	clean_cache_dir(am.cache_dir) +	actual3 := am.include_js(true) +	assert actual3.contains(expected2) == false +	assert actual3.starts_with('<script type="text/javascript" src="$am.cache_dir/') +	// Test cache path doesn't change when input files and minify setting do not. +	clean_cache_dir(am.cache_dir) +	actual4 := am.include_js(true) +	assert actual4 == actual3 +	clean_cache_dir(am.cache_dir) +} diff --git a/v_windows/v/old/vlib/vweb/request.v b/v_windows/v/old/vlib/vweb/request.v new file mode 100644 index 0000000..836775f --- /dev/null +++ b/v_windows/v/old/vlib/vweb/request.v @@ -0,0 +1,157 @@ +module vweb + +import io +import strings +import net.http +import net.urllib + +fn parse_request(mut reader io.BufferedReader) ?http.Request { +	// request line +	mut line := reader.read_line() ? +	method, target, version := parse_request_line(line) ? + +	// headers +	mut header := http.new_header() +	line = reader.read_line() ? +	for line != '' { +		key, value := parse_header(line) ? +		header.add_custom(key, value) ? +		line = reader.read_line() ? +	} +	header.coerce(canonicalize: true) + +	// body +	mut body := []byte{} +	if length := header.get(.content_length) { +		n := length.int() +		if n > 0 { +			body = []byte{len: n} +			mut count := 0 +			for count < body.len { +				count += reader.read(mut body[count..]) or { break } +			} +		} +	} + +	return http.Request{ +		method: method +		url: target.str() +		header: header +		data: body.bytestr() +		version: version +	} +} + +fn parse_request_line(s string) ?(http.Method, urllib.URL, http.Version) { +	words := s.split(' ') +	if words.len != 3 { +		return error('malformed request line') +	} +	method := http.method_from_str(words[0]) +	target := urllib.parse(words[1]) ? +	version := http.version_from_str(words[2]) +	if version == .unknown { +		return error('unsupported version') +	} + +	return method, target, version +} + +fn parse_header(s string) ?(string, string) { +	if !s.contains(':') { +		return error('missing colon in header') +	} +	words := s.split_nth(':', 2) +	// TODO: parse quoted text according to the RFC +	return words[0], words[1].trim_left(' \t') +} + +// Parse URL encoded key=value&key=value forms +fn parse_form(body string) map[string]string { +	words := body.split('&') +	mut form := map[string]string{} +	for word in words { +		kv := word.split_nth('=', 2) +		if kv.len != 2 { +			continue +		} +		key := urllib.query_unescape(kv[0]) or { continue } +		val := urllib.query_unescape(kv[1]) or { continue } +		form[key] = val +	} +	return form +	// } +	// todo: parse form-data and application/json +	// ... +} + +fn parse_multipart_form(body string, boundary string) (map[string]string, map[string][]FileData) { +	sections := body.split(boundary) +	fields := sections[1..sections.len - 1] +	mut form := map[string]string{} +	mut files := map[string][]FileData{} + +	for field in fields { +		// TODO: do not split into lines; do same parsing for HTTP body +		lines := field.split('\n')[1..] +		disposition := parse_disposition(lines[0]) +		// Grab everything between the double quotes +		name := disposition['name'] or { continue } +		// Parse files +		// TODO: filename* +		if 'filename' in disposition { +			filename := disposition['filename'] +			// Parse Content-Type header +			if lines.len == 1 || !lines[1].to_lower().starts_with('content-type:') { +				continue +			} +			mut ct := lines[1].split_nth(':', 2)[1] +			ct = ct.trim_left(' \t').trim_right('\r') +			data := lines_to_string(field.len, lines, 3, lines.len - 1) +			files[name] << FileData{ +				filename: filename +				content_type: ct +				data: data +			} +			continue +		} +		data := lines_to_string(field.len, lines, 2, lines.len - 1) +		form[name] = data +	} +	return form, files +} + +// Parse the Content-Disposition header of a multipart form +// Returns a map of the key="value" pairs +// Example: parse_disposition('Content-Disposition: form-data; name="a"; filename="b"') == {'name': 'a', 'filename': 'b'} +fn parse_disposition(line string) map[string]string { +	mut data := map[string]string{} +	for word in line.split(';') { +		kv := word.split_nth('=', 2) +		if kv.len != 2 { +			continue +		} +		key, value := kv[0].to_lower().trim_left(' \t'), kv[1].trim_right('\r') +		if value.starts_with('"') && value.ends_with('"') { +			data[key] = value[1..value.len - 1] +		} else { +			data[key] = value +		} +	} +	return data +} + +[manualfree] +fn lines_to_string(len int, lines []string, start int, end int) string { +	mut sb := strings.new_builder(len) +	for i in start .. end { +		sb.writeln(lines[i]) +	} +	sb.cut_last(1) // last newline +	if sb.last_n(1) == '\r' { +		sb.cut_last(1) +	} +	res := sb.str() +	unsafe { sb.free() } +	return res +} diff --git a/v_windows/v/old/vlib/vweb/request_test.v b/v_windows/v/old/vlib/vweb/request_test.v new file mode 100644 index 0000000..0554fc2 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/request_test.v @@ -0,0 +1,138 @@ +module vweb + +import io + +struct StringReader { +	text string +mut: +	place int +} + +fn (mut s StringReader) read(mut buf []byte) ?int { +	if s.place >= s.text.len { +		return none +	} +	max_bytes := 100 +	end := if s.place + max_bytes >= s.text.len { s.text.len } else { s.place + max_bytes } +	n := copy(buf, s.text[s.place..end].bytes()) +	s.place += n +	return n +} + +fn reader(s string) &io.BufferedReader { +	return io.new_buffered_reader( +		reader: &StringReader{ +			text: s +		} +	) +} + +fn test_parse_request_not_http() { +	mut reader_ := reader('hello') +	parse_request(mut reader_) or { return } +	panic('should not have parsed') +} + +fn test_parse_request_no_headers() { +	mut reader_ := reader('GET / HTTP/1.1\r\n\r\n') +	req := parse_request(mut reader_) or { panic('did not parse: $err') } +	assert req.method == .get +	assert req.url == '/' +	assert req.version == .v1_1 +} + +fn test_parse_request_two_headers() { +	mut reader_ := reader('GET / HTTP/1.1\r\nTest1: a\r\nTest2:  B\r\n\r\n') +	req := parse_request(mut reader_) or { panic('did not parse: $err') } +	assert req.header.custom_values('Test1') == ['a'] +	assert req.header.custom_values('Test2') == ['B'] +} + +fn test_parse_request_two_header_values() { +	mut reader_ := reader('GET / HTTP/1.1\r\nTest1: a; b\r\nTest2: c\r\nTest2: d\r\n\r\n') +	req := parse_request(mut reader_) or { panic('did not parse: $err') } +	assert req.header.custom_values('Test1') == ['a; b'] +	assert req.header.custom_values('Test2') == ['c', 'd'] +} + +fn test_parse_request_body() { +	mut reader_ := reader('GET / HTTP/1.1\r\nTest1: a\r\nTest2: b\r\nContent-Length: 4\r\n\r\nbodyabc') +	req := parse_request(mut reader_) or { panic('did not parse: $err') } +	assert req.data == 'body' +} + +fn test_parse_request_line() { +	method, target, version := parse_request_line('GET /target HTTP/1.1') or { +		panic('did not parse: $err') +	} +	assert method == .get +	assert target.str() == '/target' +	assert version == .v1_1 +} + +fn test_parse_form() { +	assert parse_form('foo=bar&bar=baz') == map{ +		'foo': 'bar' +		'bar': 'baz' +	} +	assert parse_form('foo=bar=&bar=baz') == map{ +		'foo': 'bar=' +		'bar': 'baz' +	} +	assert parse_form('foo=bar%3D&bar=baz') == map{ +		'foo': 'bar=' +		'bar': 'baz' +	} +	assert parse_form('foo=b%26ar&bar=baz') == map{ +		'foo': 'b&ar' +		'bar': 'baz' +	} +	assert parse_form('a=b& c=d') == map{ +		'a':  'b' +		' c': 'd' +	} +	assert parse_form('a=b&c= d ') == map{ +		'a': 'b' +		'c': ' d ' +	} +} + +fn test_parse_multipart_form() { +	boundary := '6844a625b1f0b299' +	names := ['foo', 'fooz'] +	file := 'bar.v' +	ct := 'application/octet-stream' +	contents := ['baz', 'buzz'] +	data := "--------------------------$boundary +Content-Disposition: form-data; name=\"${names[0]}\"; filename=\"$file\" +Content-Type: $ct + +${contents[0]} +--------------------------$boundary +Content-Disposition: form-data; name=\"${names[1]}\" + +${contents[1]} +--------------------------$boundary-- +" +	form, files := parse_multipart_form(data, boundary) +	assert files == map{ +		names[0]: [FileData{ +			filename: file +			content_type: ct +			data: contents[0] +		}] +	} + +	assert form == map{ +		names[1]: contents[1] +	} +} + +fn test_parse_large_body() ? { +	body := 'ABCEF\r\n'.repeat(1024 * 1024) // greater than max_bytes +	req := 'GET / HTTP/1.1\r\nContent-Length: $body.len\r\n\r\n$body' +	mut reader_ := reader(req) +	result := parse_request(mut reader_) ? +	assert result.data.len == body.len +	assert result.data == body +} diff --git a/v_windows/v/old/vlib/vweb/route_test.v b/v_windows/v/old/vlib/vweb/route_test.v new file mode 100644 index 0000000..2025fa3 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/route_test.v @@ -0,0 +1,270 @@ +module vweb + +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']) +} diff --git a/v_windows/v/old/vlib/vweb/sse/sse.v b/v_windows/v/old/vlib/vweb/sse/sse.v new file mode 100644 index 0000000..986c618 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/sse/sse.v @@ -0,0 +1,77 @@ +module sse + +import net +import time +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. + +[heap] +pub struct SSEConnection { +pub mut: +	headers       map[string]string +	conn          &net.TcpConn +	write_timeout time.Duration = 600 * time.second +} + +pub struct SSEMessage { +	id    string +	event string +	data  string +	retry int +} + +pub fn new_connection(conn &net.TcpConn) &SSEConnection { +	return &SSEConnection{ +		conn: unsafe { conn } +	} +} + +// sse_start is used to send the start of a Server Side Event response. +pub fn (mut sse SSEConnection) start() ? { +	sse.conn.set_write_timeout(sse.write_timeout) +	mut start_sb := strings.new_builder(512) +	start_sb.write_string('HTTP/1.1 200') +	start_sb.write_string('\r\nConnection: keep-alive') +	start_sb.write_string('\r\nCache-Control: no-cache') +	start_sb.write_string('\r\nContent-Type: text/event-stream') +	for k, v in sse.headers { +		start_sb.write_string('\r\n$k: $v') +	} +	start_sb.write_string('\r\n') +	sse.conn.write(start_sb) or { return error('could not start sse response') } +} + +// 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) ? { +	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) ? +} diff --git a/v_windows/v/old/vlib/vweb/tests/vweb_test.v b/v_windows/v/old/vlib/vweb/tests/vweb_test.v new file mode 100644 index 0000000..f858e15 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/tests/vweb_test.v @@ -0,0 +1,298 @@ +import os +import time +import json +import net +import net.http +import io + +const ( +	sport           = 12380 +	exit_after_time = 12000 // milliseconds +	vexe            = os.getenv('VEXE') +	vweb_logfile    = os.getenv('VWEB_LOGFILE') +	vroot           = os.dir(vexe) +	serverexe       = os.join_path(os.cache_dir(), 'vweb_test_server.exe') +	tcp_r_timeout   = 30 * time.second +	tcp_w_timeout   = 30 * time.second +) + +// setup of vweb webserver +fn testsuite_begin() { +	os.chdir(vroot) +	if os.exists(serverexe) { +		os.rm(serverexe) or {} +	} +} + +fn test_a_simple_vweb_app_can_be_compiled() { +	// did_server_compile := os.system('$vexe -g -o $serverexe vlib/vweb/tests/vweb_test_server.v') +	// TODO: find out why it does not compile with -usecache and -g +	did_server_compile := os.system('$vexe -o $serverexe vlib/vweb/tests/vweb_test_server.v') +	assert did_server_compile == 0 +	assert os.exists(serverexe) +} + +fn test_a_simple_vweb_app_runs_in_the_background() { +	mut suffix := '' +	$if !windows { +		suffix = ' > /dev/null &' +	} +	if vweb_logfile != '' { +		suffix = ' 2>> $vweb_logfile >> $vweb_logfile &' +	} +	server_exec_cmd := '$serverexe $sport $exit_after_time $suffix' +	$if debug_net_socket_client ? { +		eprintln('running:\n$server_exec_cmd') +	} +	$if windows { +		go 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') +	assert received.contains('Server: VWeb\r\n') +	assert received.contains('Content-Length:') +	assert received.contains('Connection: close\r\n') +} + +fn test_a_simple_tcp_client_can_connect_to_the_vweb_server() { +	received := simple_tcp_client(path: '/') 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('Welcome to VWeb') +} + +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_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) ? == 'VWeb' +	assert x.header.get(.content_length) ?.int() > 0 +	assert x.header.get(.connection) ? == 'close' +} + +fn test_http_client_index() ? { +	x := http.get('http://127.0.0.1:$sport/') or { panic(err) } +	assert_common_http_headers(x) ? +	assert x.header.get(.content_type) ? == 'text/plain' +	assert x.text == 'Welcome to VWeb' +} + +fn test_http_client_404() ? { +	url_404_list := [ +		'http://127.0.0.1:$sport/zxcnbnm', +		'http://127.0.0.1:$sport/JHKAJA', +		'http://127.0.0.1:$sport/unknown', +	] +	for url in url_404_list { +		res := http.get(url) or { panic(err) } +		assert res.status() == .not_found +	} +} + +fn test_http_client_simple() ? { +	x := http.get('http://127.0.0.1:$sport/simple') or { panic(err) } +	assert_common_http_headers(x) ? +	assert x.header.get(.content_type) ? == 'text/plain' +	assert x.text == 'A simple result' +} + +fn test_http_client_html_page() ? { +	x := http.get('http://127.0.0.1:$sport/html_page') or { panic(err) } +	assert_common_http_headers(x) ? +	assert x.header.get(.content_type) ? == 'text/html' +	assert x.text == '<h1>ok</h1>' +} + +fn test_http_client_settings_page() ? { +	x := http.get('http://127.0.0.1:$sport/bilbo/settings') or { panic(err) } +	assert_common_http_headers(x) ? +	assert x.text == 'username: bilbo' +	// +	y := http.get('http://127.0.0.1:$sport/kent/settings') or { panic(err) } +	assert_common_http_headers(y) ? +	assert y.text == 'username: kent' +} + +fn test_http_client_user_repo_settings_page() ? { +	x := http.get('http://127.0.0.1:$sport/bilbo/gostamp/settings') or { panic(err) } +	assert_common_http_headers(x) ? +	assert x.text == 'username: bilbo | repository: gostamp' +	// +	y := http.get('http://127.0.0.1:$sport/kent/golang/settings') or { panic(err) } +	assert_common_http_headers(y) ? +	assert y.text == 'username: kent | repository: golang' +	// +	z := http.get('http://127.0.0.1:$sport/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://127.0.0.1:$sport/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.text == json_for_ouser +	nuser := json.decode(User, x.text) or { User{} } +	assert '$ouser' == '$nuser' +	// +	x = http.post_json('http://127.0.0.1:$sport/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.text == json_for_ouser +	nuser2 := json.decode(User, x.text) or { User{} } +	assert '$ouser' == '$nuser2' +} + +fn test_http_client_multipart_form_data() ? { +	boundary := '6844a625b1f0b299' +	name := 'foo' +	ct := 'multipart/form-data; boundary=------------------------$boundary' +	contents := 'baz buzz' +	data := "--------------------------$boundary +Content-Disposition: form-data; name=\"$name\" + +$contents +--------------------------$boundary-- +" +	mut x := http.fetch('http://127.0.0.1:$sport/form_echo', +		method: .post +		header: http.new_header( +			key: .content_type +			value: ct +		) +		data: data +	) ? +	$if debug_net_socket_client ? { +		eprintln('/form_echo endpoint response: $x') +	} +	assert x.text == contents +} + +fn test_http_client_shutdown_does_not_work_without_a_cookie() { +	x := http.get('http://127.0.0.1:$sport/shutdown') or { +		assert err.msg == '' +		return +	} +	assert x.status() == .not_found +	assert x.text == '404 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('http://127.0.0.1:$sport/shutdown', +		method: .get +		cookies: map{ +			'skey': 'superman' +		} +	) or { +		assert err.msg == '' +		return +	} +	assert x.status() == .ok +	assert x.text == 'good bye' +} + +// utility code: +struct SimpleTcpClientConfig { +	retries int    = 20 +	host    string = 'static.dev' +	path    string = '/' +	agent   string = 'v/net.tcp.v' +	headers string = '\r\n' +	content string +} + +fn simple_tcp_client(config SimpleTcpClientConfig) ?string { +	mut client := &net.TcpConn(0) +	mut tries := 0 +	for tries < config.retries { +		tries++ +		client = net.dial_tcp('127.0.0.1:$sport') or { +			if tries > config.retries { +				return err +			} +			time.sleep(100 * time.millisecond) +			continue +		} +		break +	} +	client.set_read_timeout(tcp_r_timeout) +	client.set_write_timeout(tcp_w_timeout) +	defer { +		client.close() or {} +	} +	message := 'GET $config.path HTTP/1.1 +Host: $config.host +User-Agent: $config.agent +Accept: */* +$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() +} diff --git a/v_windows/v/old/vlib/vweb/tests/vweb_test_server.v b/v_windows/v/old/vlib/vweb/tests/vweb_test_server.v new file mode 100644 index 0000000..4dfeb7d --- /dev/null +++ b/v_windows/v/old/vlib/vweb/tests/vweb_test_server.v @@ -0,0 +1,118 @@ +module main + +import os +import vweb +import time + +const ( +	known_users = ['bilbo', 'kent'] +) + +struct App { +	vweb.Context +	port          int +	timeout       int +	global_config shared Config +} + +struct Config { +	max_ping int +} + +fn exit_after_timeout(timeout_in_ms int) { +	time.sleep(timeout_in_ms * time.millisecond) +	// eprintln('webserver is exiting ...') +	exit(0) +} + +fn main() { +	if os.args.len != 3 { +		panic('Usage: `vweb_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 +	go exit_after_timeout(timeout) +	// +	shared config := &Config{ +		max_ping: 50 +	} +	app := &App{ +		port: http_port +		timeout: timeout +		global_config: config +	} +	eprintln('>> webserver: started on http://127.0.0.1:$app.port/ , with maximum runtime of $app.timeout milliseconds.') +	// vweb.run<App>(mut app, http_port) +	vweb.run(app, http_port) +} + +// pub fn (mut app App) init_server() { +//} + +pub fn (mut app App) index() vweb.Result { +	assert app.global_config.max_ping == 50 +	return app.text('Welcome to VWeb') +} + +pub fn (mut app App) simple() vweb.Result { +	return app.text('A simple result') +} + +pub fn (mut app App) html_page() vweb.Result { +	return app.html('<h1>ok</h1>') +} + +// the following serve custom routes +['/:user/settings'] +pub fn (mut app App) settings(username string) vweb.Result { +	if username !in known_users { +		return app.not_found() +	} +	return app.html('username: $username') +} + +['/:user/:repo/settings'] +pub fn (mut app App) user_repo_settings(username string, repository string) vweb.Result { +	if username !in known_users { +		return app.not_found() +	} +	return app.html('username: $username | repository: $repository') +} + +['/json_echo'; post] +pub fn (mut app App) json_echo() vweb.Result { +	// eprintln('>>>>> received http request at /json_echo is: $app.req') +	app.set_content_type(app.req.header.get(.content_type) or { '' }) +	return app.ok(app.req.data) +} + +['/form_echo'; post] +pub fn (mut app App) form_echo() vweb.Result { +	app.set_content_type(app.req.header.get(.content_type) or { '' }) +	return app.ok(app.form['foo']) +} + +// Make sure [post] works without the path +[post] +pub fn (mut app App) json() vweb.Result { +	// eprintln('>>>>> received http request at /json is: $app.req') +	app.set_content_type(app.req.header.get(.content_type) or { '' }) +	return app.ok(app.req.data) +} + +pub fn (mut app App) shutdown() vweb.Result { +	session_key := app.get_cookie('skey') or { return app.not_found() } +	if session_key != 'superman' { +		return app.not_found() +	} +	go app.gracefull_exit() +	return app.ok('good bye') +} + +fn (mut app App) gracefull_exit() { +	eprintln('>> webserver: gracefull_exit') +	time.sleep(100 * time.millisecond) +	exit(0) +} diff --git a/v_windows/v/old/vlib/vweb/vweb.v b/v_windows/v/old/vlib/vweb/vweb.v new file mode 100644 index 0000000..956e7f6 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/vweb.v @@ -0,0 +1,640 @@ +// Copyright (c) 2019-2021 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 vweb + +import os +import io +import net +import net.http +import net.urllib +import time + +pub const ( +	methods_with_form = [http.Method.post, .put, .patch] +	headers_close     = http.new_custom_header_from_map(map{ +		'Server':          'VWeb' +		http.CommonHeader.connection.str(): 'close' +	}) or { panic('should never fail') } + +	http_400          = http.new_response( +		status: .bad_request +		text: '400 Bad Request' +		header: http.new_header( +			key: .content_type +			value: 'text/plain' +		).join(headers_close) +	) +	http_404          = http.new_response( +		status: .not_found +		text: '404 Not Found' +		header: http.new_header( +			key: .content_type +			value: 'text/plain' +		).join(headers_close) +	) +	http_500          = http.new_response( +		status: .internal_server_error +		text: '500 Internal Server Error' +		header: http.new_header( +			key: .content_type +			value: 'text/plain' +		).join(headers_close) +	) +	mime_types        = map{ +		'.css':  'text/css; charset=utf-8' +		'.gif':  'image/gif' +		'.htm':  'text/html; charset=utf-8' +		'.html': 'text/html; charset=utf-8' +		'.jpg':  'image/jpeg' +		'.js':   'application/javascript' +		'.json': 'application/json' +		'.md':   'text/markdown; charset=utf-8' +		'.pdf':  'application/pdf' +		'.png':  'image/png' +		'.svg':  'image/svg+xml' +		'.txt':  'text/plain; charset=utf-8' +		'.wasm': 'application/wasm' +		'.xml':  'text/xml; charset=utf-8' +		'.ico':  'img/x-icon' +	} +	max_http_post_size = 1024 * 1024 +	default_port       = 8080 +) + +pub struct Context { +mut: +	content_type string = 'text/plain' +	status       string = '200 OK' +pub: +	req http.Request +	// TODO Response +pub mut: +	conn              &net.TcpConn +	static_files      map[string]string +	static_mime_types map[string]string +	form              map[string]string +	query             map[string]string +	files             map[string][]FileData +	header            http.Header // response headers +	done              bool +	page_gen_start    i64 +	form_error        string +} + +struct FileData { +pub: +	filename     string +	content_type string +	data         string +} + +struct UnexpectedExtraAttributeError { +	msg  string +	code int +} + +struct MultiplePathAttributesError { +	msg  string = 'Expected at most one path attribute' +	code int +} + +// declaring init_server in your App struct is optional +pub fn (ctx Context) init_server() {} + +// declaring before_request in your App struct is optional +pub fn (ctx Context) before_request() {} + +pub struct Cookie { +	name      string +	value     string +	expires   time.Time +	secure    bool +	http_only bool +} + +[noinit] +pub struct Result { +} + +// vweb intern function +[manualfree] +pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bool { +	if ctx.done { +		return false +	} +	ctx.done = true + +	// build header +	header := http.new_header_from_map(map{ +		http.CommonHeader.content_type:   mimetype +		http.CommonHeader.content_length: res.len.str() +	}).join(ctx.header) + +	mut resp := http.Response{ +		header: header.join(vweb.headers_close) +		text: res +	} +	resp.set_version(.v1_1) +	resp.set_status(http.status_from_int(ctx.status.int())) +	send_string(mut ctx.conn, resp.bytestr()) or { return false } +	return true +} + +// Response HTTP_OK with s as payload with content-type `text/html` +pub fn (mut ctx Context) html(s string) Result { +	ctx.send_response_to_client('text/html', s) +	return Result{} +} + +// Response HTTP_OK with s as payload with content-type `text/plain` +pub fn (mut ctx Context) text(s string) Result { +	ctx.send_response_to_client('text/plain', s) +	return Result{} +} + +// Response HTTP_OK with s as payload with content-type `application/json` +pub fn (mut ctx Context) json(s string) Result { +	ctx.send_response_to_client('application/json', s) +	return Result{} +} + +// Response HTTP_OK with s as payload +pub fn (mut ctx Context) ok(s string) Result { +	ctx.send_response_to_client(ctx.content_type, s) +	return Result{} +} + +// Response a server error +pub fn (mut ctx Context) server_error(ecode int) Result { +	$if debug { +		eprintln('> ctx.server_error ecode: $ecode') +	} +	if ctx.done { +		return Result{} +	} +	send_string(mut ctx.conn, vweb.http_500.bytestr()) or {} +	return Result{} +} + +// Redirect to an url +pub fn (mut ctx Context) redirect(url string) Result { +	if ctx.done { +		return Result{} +	} +	ctx.done = true +	send_string(mut ctx.conn, 'HTTP/1.1 302 Found\r\nLocation: $url$ctx.header\r\n$vweb.headers_close\r\n') or { +		return Result{} +	} +	return Result{} +} + +// Send an not_found response +pub fn (mut ctx Context) not_found() Result { +	if ctx.done { +		return Result{} +	} +	ctx.done = true +	send_string(mut ctx.conn, vweb.http_404.bytestr()) or {} +	return Result{} +} + +// Sets a cookie +pub fn (mut ctx Context) set_cookie(cookie Cookie) { +	mut cookie_data := []string{} +	mut secure := if cookie.secure { 'Secure;' } else { '' } +	secure += if cookie.http_only { ' HttpOnly' } else { ' ' } +	cookie_data << secure +	if cookie.expires.unix > 0 { +		cookie_data << 'expires=$cookie.expires.utc_string()' +	} +	data := cookie_data.join(' ') +	ctx.add_header('Set-Cookie', '$cookie.name=$cookie.value; $data') +} + +// Sets the response content type +pub fn (mut ctx Context) set_content_type(typ string) { +	ctx.content_type = typ +} + +// Sets a cookie with a `expire_data` +pub fn (mut ctx Context) set_cookie_with_expire_date(key string, val string, expire_date time.Time) { +	ctx.add_header('Set-Cookie', '$key=$val;  Secure; HttpOnly; expires=$expire_date.utc_string()') +} + +// Gets a cookie by a key +pub fn (ctx &Context) get_cookie(key string) ?string { // TODO refactor +	mut cookie_header := ctx.get_header('cookie') +	if cookie_header == '' { +		cookie_header = ctx.get_header('Cookie') +	} +	cookie_header = ' ' + cookie_header +	// println('cookie_header="$cookie_header"') +	// println(ctx.req.header) +	cookie := if cookie_header.contains(';') { +		cookie_header.find_between(' $key=', ';') +	} else { +		cookie_header.find_between(' $key=', '\r') +	} +	if cookie != '' { +		return cookie.trim_space() +	} +	return error('Cookie not found') +} + +// Sets the response status +pub fn (mut ctx Context) set_status(code int, desc string) { +	if code < 100 || code > 599 { +		ctx.status = '500 Internal Server Error' +	} else { +		ctx.status = '$code $desc' +	} +} + +// Adds an header to the response with key and val +pub fn (mut ctx Context) add_header(key string, val string) { +	ctx.header.add_custom(key, val) or {} +} + +// Returns the header data from the key +pub fn (ctx &Context) get_header(key string) string { +	return ctx.req.header.get_custom(key) or { '' } +} + +/* +pub fn run<T>(port int) { +	mut x := &T{} +	run_app(mut x, port) +} +*/ + +interface DbInterface { +	db voidptr +} + +// run_app +[manualfree] +pub fn run<T>(global_app &T, port int) { +	// x := global_app.clone() +	// mut global_app := &T{} +	// mut app := &T{} +	// run_app<T>(mut app, port) + +	mut l := net.listen_tcp(.ip6, ':$port') or { panic('failed to listen $err.code $err') } + +	println('[Vweb] Running app on http://localhost:$port') +	// app.Context = Context{ +	// conn: 0 +	//} +	// app.init_server() +	// global_app.init_server() +	//$for method in T.methods { +	//$if method.return_type is Result { +	// check routes for validity +	//} +	//} +	for { +		// Create a new app object for each connection, copy global data like db connections +		mut request_app := &T{} +		$if T is DbInterface { +			request_app.db = global_app.db +		} $else { +			// println('vweb no db') +		} +		$for field in T.fields { +			if field.is_shared { +				request_app.$(field.name) = global_app.$(field.name) +			} +		} +		request_app.Context = global_app.Context // copy the context ref that contains static files map etc +		// request_app.Context = Context{ +		// conn: 0 +		//} +		mut conn := l.accept() or { +			// failures should not panic +			eprintln('accept() failed with error: $err.msg') +			continue +		} +		go handle_conn<T>(mut conn, mut request_app) +	} +} + +[manualfree] +fn handle_conn<T>(mut conn net.TcpConn, mut app T) { +	conn.set_read_timeout(30 * time.second) +	conn.set_write_timeout(30 * time.second) +	defer { +		conn.close() or {} +		unsafe { +			free(app) +		} +	} +	mut reader := io.new_buffered_reader(reader: conn) +	defer { +		reader.free() +	} +	page_gen_start := time.ticks() +	req := parse_request(mut reader) or { +		// Prevents errors from being thrown when BufferedReader is empty +		if '$err' != 'none' { +			eprintln('error parsing request: $err') +		} +		return +	} +	app.Context = Context{ +		req: req +		conn: conn +		form: map[string]string{} +		static_files: app.static_files +		static_mime_types: app.static_mime_types +		page_gen_start: page_gen_start +	} +	if req.method in vweb.methods_with_form { +		ct := req.header.get(.content_type) or { '' }.split(';').map(it.trim_left(' \t')) +		if 'multipart/form-data' in ct { +			boundary := ct.filter(it.starts_with('boundary=')) +			if boundary.len != 1 { +				send_string(mut conn, vweb.http_400.bytestr()) or {} +				return +			} +			form, files := parse_multipart_form(req.data, boundary[0][9..]) +			for k, v in form { +				app.form[k] = v +			} +			for k, v in files { +				app.files[k] = v +			} +		} else { +			form := parse_form(req.data) +			for k, v in form { +				app.form[k] = v +			} +		} +	} +	// Serve a static file if it is one +	// TODO: get the real path +	url := urllib.parse(app.req.url) or { +		eprintln('error parsing path: $err') +		return +	} +	if serve_if_static<T>(mut app, url) { +		// successfully served a static file +		return +	} + +	app.before_request() +	// Call the right action +	$if debug { +		println('route matching...') +	} +	url_words := url.path.split('/').filter(it != '') +	// copy query args to app.query +	for k, v in url.query().data { +		app.query[k] = v.data[0] +	} + +	$for method in T.methods { +		$if method.return_type is Result { +			mut method_args := []string{} +			// TODO: move to server start +			http_methods, route_path := parse_attrs(method.name, method.attrs) or { +				eprintln('error parsing method attributes: $err') +				return +			} + +			// Used for route matching +			route_words := route_path.split('/').filter(it != '') + +			// Skip if the HTTP request method does not match the attributes +			if app.req.method in http_methods { +				// 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 +					app.$method() +					return +				} + +				if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { +					app.$method() +					return +				} + +				if params := route_matches(url_words, route_words) { +					method_args = params.clone() +					if method_args.len != method.args.len { +						eprintln('warning: uneven parameters count ($method.args.len) in `$method.name`, compared to the vweb route `$method.attrs` ($method_args.len)') +					} +					app.$method(method_args) +					return +				} +			} +		} +	} +	// site not found +	send_string(mut conn, vweb.http_404.bytestr()) or {} +} + +fn route_matches(url_words []string, route_words []string) ?[]string { +	// URL path should be at least as long as the route path +	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 paramater +				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 paramater +			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 +} + +// parse function attribute list for methods and a path +fn parse_attrs(name string, attrs []string) ?([]http.Method, string) { +	if attrs.len == 0 { +		return [http.Method.get], '/$name' +	} + +	mut x := attrs.clone() +	mut methods := []http.Method{} +	mut path := '' + +	for i := 0; i < x.len; { +		attr := x[i] +		attru := attr.to_upper() +		m := http.method_from_str(attru) +		if attru == 'GET' || m != .get { +			methods << m +			x.delete(i) +			continue +		} +		if attr.starts_with('/') { +			if path != '' { +				return IError(&MultiplePathAttributesError{}) +			} +			path = attr +			x.delete(i) +			continue +		} +		i++ +	} +	if x.len > 0 { +		return IError(&UnexpectedExtraAttributeError{ +			msg: 'Encountered unexpected extra attributes: $x' +		}) +	} +	if methods.len == 0 { +		methods = [http.Method.get] +	} +	if path == '' { +		path = '/$name' +	} +	// Make path lowercase for case-insensitive comparisons +	return methods, path.to_lower() +} + +// check if request is for a static file and serves it +// returns true if we served a static file, false otherwise +[manualfree] +fn serve_if_static<T>(mut app T, url urllib.URL) bool { +	// TODO: handle url parameters properly - for now, ignore them +	static_file := app.static_files[url.path] +	mime_type := app.static_mime_types[url.path] +	if static_file == '' || mime_type == '' { +		return false +	} +	data := os.read_file(static_file) or { +		send_string(mut app.conn, vweb.http_404.bytestr()) or {} +		return true +	} +	app.send_response_to_client(mime_type, data) +	unsafe { data.free() } +	return true +} + +fn (mut ctx Context) scan_static_directory(directory_path string, mount_path 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) { +				ctx.scan_static_directory(full_path, mount_path + '/' + file) +			} else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') { +				ext := os.file_ext(file) +				// Rudimentary guard against adding files not in mime_types. +				// Use serve_static directly to add non-standard mime types. +				if ext in vweb.mime_types { +					ctx.serve_static(mount_path + '/' + file, full_path) +				} +			} +		} +	} +} + +// Handles a directory static +// If `root` is set the mount path for the dir will be in '/' +pub fn (mut ctx Context) handle_static(directory_path string, root bool) bool { +	if ctx.done || !os.exists(directory_path) { +		return false +	} +	dir_path := directory_path.trim_space().trim_right('/') +	mut mount_path := '' +	if dir_path != '.' && os.is_dir(dir_path) && !root { +		// Mount point hygene, "./assets" => "/assets". +		mount_path = '/' + dir_path.trim_left('.').trim('/') +	} +	ctx.scan_static_directory(dir_path, mount_path) +	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 ctx Context) mount_static_folder_at(directory_path string, mount_path string) bool { +	if ctx.done || mount_path.len < 1 || mount_path[0] != `/` || !os.exists(directory_path) { +		return false +	} +	dir_path := directory_path.trim_right('/') +	ctx.scan_static_directory(dir_path, mount_path[1..]) +	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 ctx Context) serve_static(url string, file_path string) { +	ctx.static_files[url] = file_path +	// ctx.static_mime_types[url] = mime_type +	ext := os.file_ext(file_path) +	ctx.static_mime_types[url] = vweb.mime_types[ext] +} + +// Returns the ip address from the current user +pub fn (ctx &Context) ip() string { +	mut ip := ctx.req.header.get(.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 == '' { +		ip = ctx.conn.peer_ip() or { '' } +	} +	return ip +} + +// Set s to the form error +pub fn (mut ctx Context) error(s string) { +	println('vweb error: $s') +	ctx.form_error = s +} + +// Returns an empty result +pub fn not_found() Result { +	return Result{} +} + +fn filter(s string) string { +	return s.replace_each([ +		'<', +		'<', +		'"', +		'"', +		'&', +		'&', +	]) +} + +// A type which don't get filtered inside templates +pub type RawHtml = string + +fn send_string(mut conn net.TcpConn, s string) ? { +	conn.write(s.bytes()) ? +} diff --git a/v_windows/v/old/vlib/vweb/vweb_app_test.v b/v_windows/v/old/vlib/vweb/vweb_app_test.v new file mode 100644 index 0000000..492bdf4 --- /dev/null +++ b/v_windows/v/old/vlib/vweb/vweb_app_test.v @@ -0,0 +1,63 @@ +module main + +import vweb +import time +import sqlite + +struct App { +	vweb.Context +pub mut: +	db      sqlite.DB [server_var] +	user_id string +} + +struct Article { +	id    int +	title string +	text  string +} + +fn test_a_vweb_application_compiles() { +	go fn () { +		time.sleep(2 * time.second) +		exit(0) +	}() +	vweb.run(&App{}, 18081) +} + +pub fn (mut app App) init_server() { +	app.db = sqlite.connect('blog.db') or { panic(err) } +	app.db.create_table('article', [ +		'id integer primary key', +		"title text default ''", +		"text text default ''", +	]) +} + +pub fn (mut app App) before_request() { +	app.user_id = app.get_cookie('id') or { '0' } +} + +['/new_article'; post] +pub fn (mut app App) new_article() vweb.Result { +	title := app.form['title'] +	text := app.form['text'] +	if title == '' || text == '' { +		return app.text('Empty text/title') +	} +	article := Article{ +		title: title +		text: text +	} +	println('posting article') +	println(article) +	sql app.db { +		insert article into Article +	} + +	return app.redirect('/') +} + +fn (mut app App) time() { +	app.text(time.now().format()) +} | 
