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()) +} |