diff --git a/cmd/tools/modules/testing/common.v b/cmd/tools/modules/testing/common.v index 52e2c3c2d6..77209bdac8 100644 --- a/cmd/tools/modules/testing/common.v +++ b/cmd/tools/modules/testing/common.v @@ -250,6 +250,7 @@ pub fn new_test_session(_vargs string, will_compile bool) TestSession { skip_files << 'examples/call_v_from_python/test.v' // the example only makes sense to be compiled, when python is installed skip_files << 'examples/call_v_from_ruby/test.v' // the example only makes sense to be compiled, when ruby is installed skip_files << 'vlib/vweb/vweb_app_test.v' // imports the `sqlite` module, which in turn includes sqlite3.h + skip_files << 'vlib/x/vweb/tests/vweb_app_test.v' // imports the `sqlite` module, which in turn includes sqlite3.h } $if !macos { skip_files << 'examples/macos_tray/tray.v' diff --git a/cmd/tools/vtest-self.v b/cmd/tools/vtest-self.v index 9bdc1b95c4..db0824e345 100644 --- a/cmd/tools/vtest-self.v +++ b/cmd/tools/vtest-self.v @@ -259,6 +259,8 @@ const skip_on_ubuntu_musl = [ 'vlib/net/smtp/smtp_test.v', 'vlib/v/tests/websocket_logger_interface_should_compile_test.v', 'vlib/v/tests/fn_literal_type_test.v', + 'vlib/vweb/x/tests/vweb_test.v', + 'vlib/vweb/x/tests/vweb_app_test.v', ] const skip_on_linux = [ 'do_not_remove', diff --git a/examples/pico/pico.v b/examples/pico/pico.v index 3ddd6b139d..ca4b76f109 100644 --- a/examples/pico/pico.v +++ b/examples/pico/pico.v @@ -49,7 +49,7 @@ fn callback(data voidptr, req picohttpparser.Request, mut res picohttpparser.Res } fn main() { - println('Starting webserver on http://127.0.0.1:${port}/ ...') + println('Starting webserver on http://localhost:${port}/ ...') mut server := picoev.new(port: port, cb: callback) server.serve() } diff --git a/examples/pico/raw_callback.v b/examples/pico/raw_callback.v index fb1b06f21a..181da7d0aa 100644 --- a/examples/pico/raw_callback.v +++ b/examples/pico/raw_callback.v @@ -4,6 +4,7 @@ import net import picoev const port = 8080 + const http_response = 'HTTP/1.1 200 OK\r\nContent-type: text/html\r\nContent-length: 18\r\n\r\nHello from Picoev!' fn main() { @@ -15,7 +16,7 @@ fn main() { pico.serve() } -fn handle_conn(mut pv picoev.Picoev, fd int) { +fn handle_conn(mut pv picoev.Picoev, fd int, events int) { // setup a nonblocking tcp connection mut conn := &net.TcpConn{ sock: net.tcp_socket_from_handle_raw(fd) diff --git a/vlib/io/buffered_reader.v b/vlib/io/buffered_reader.v index 911acfc6c5..9d8633129a 100644 --- a/vlib/io/buffered_reader.v +++ b/vlib/io/buffered_reader.v @@ -11,6 +11,7 @@ mut: mfails int // maximum fails, after which we can assume that the stream has ended pub mut: end_of_stream bool // whether we reached the end of the upstream reader + total_read int // total number of bytes read } // BufferedReaderConfig are options that can be given to a buffered reader. @@ -55,6 +56,7 @@ pub fn (mut r BufferedReader) read(mut buf []u8) !int { } } r.offset += read + r.total_read += read return read } @@ -128,6 +130,7 @@ pub fn (mut r BufferedReader) read_line() !string { // try and find a newline character mut i := r.offset for ; i < r.len; i++ { + r.total_read++ c := r.buf[i] if c == `\n` { // great, we hit something diff --git a/vlib/io/reader_test.v b/vlib/io/reader_test.v index 62ba3ce3c1..e715832f62 100644 --- a/vlib/io/reader_test.v +++ b/vlib/io/reader_test.v @@ -126,3 +126,38 @@ fn test_leftover() { } assert r.end_of_stream() } + +fn test_totalread_read() { + text := 'Some testing text' + mut s := StringReader{ + text: text + } + mut r := new_buffered_reader(reader: s) + + mut buf := []u8{len: text.len} + total := r.read(mut buf) or { + assert false + panic('bad') + } + + assert r.total_read == total +} + +fn test_totalread_readline() { + text := 'Some testing text\nmore_enters' + mut s := StringReader{ + text: text + } + mut r := new_buffered_reader(reader: s) + + _ := r.read_line() or { + assert false + panic('bad') + } + _ := r.read_line() or { + assert false + panic('bad') + } + + assert r.total_read == text.len +} diff --git a/vlib/picoev/picoev.v b/vlib/picoev/picoev.v index 3e2150e7e6..82e6f40b16 100644 --- a/vlib/picoev/picoev.v +++ b/vlib/picoev/picoev.v @@ -1,17 +1,24 @@ module picoev +import net import picohttpparser import time pub const max_fds = 1024 + pub const max_queue = 4096 // events pub const picoev_read = 1 + pub const picoev_write = 2 + pub const picoev_timeout = 4 + pub const picoev_add = 0x40000000 + pub const picoev_del = 0x20000000 + pub const picoev_readwrite = 3 // Target is a data representation of everything that needs to be associated with a single @@ -31,19 +38,21 @@ pub: port int = 8080 cb fn (voidptr, picohttpparser.Request, mut picohttpparser.Response) = unsafe { nil } err_cb fn (voidptr, picohttpparser.Request, mut picohttpparser.Response, IError) = default_err_cb - raw_cb fn (mut Picoev, int) = unsafe { nil } - user_data voidptr = unsafe { nil } - timeout_secs int = 8 - max_headers int = 100 - max_read int = 4096 - max_write int = 8192 + raw_cb fn (mut Picoev, int, int) = unsafe { nil } + user_data voidptr = unsafe { nil } + timeout_secs int = 8 + max_headers int = 100 + max_read int = 4096 + max_write int = 8192 + family net.AddrFamily = .ip + host string = 'localhost' } @[heap] pub struct Picoev { cb fn (voidptr, picohttpparser.Request, mut picohttpparser.Response) = unsafe { nil } err_cb fn (voidptr, picohttpparser.Request, mut picohttpparser.Response, IError) = default_err_cb - raw_cb fn (mut Picoev, int) = unsafe { nil } + raw_cb fn (mut Picoev, int, int) = unsafe { nil } timeout_secs int max_headers int = 100 @@ -98,7 +107,7 @@ pub fn (mut pv Picoev) add(fd int, events int, timeout int, cb voidptr) int { // del removes a file descriptor from the loop @[direct_array_access] -fn (mut pv Picoev) del(fd int) int { +pub fn (mut pv Picoev) del(fd int) int { assert fd < picoev.max_fds mut target := pv.file_descriptors[fd] @@ -212,7 +221,7 @@ fn raw_callback(fd int, events int, context voidptr) { } else if events & picoev.picoev_read != 0 { pv.set_timeout(fd, pv.timeout_secs) if !isnil(pv.raw_cb) { - pv.raw_cb(mut pv, fd) + pv.raw_cb(mut pv, fd, events) return } @@ -272,6 +281,12 @@ fn raw_callback(fd int, events int, context voidptr) { // Callback (should call .end() itself) pv.cb(pv.user_data, req, mut &res) + } else if events & picoev.picoev_write != 0 { + pv.set_timeout(fd, pv.timeout_secs) + if !isnil(pv.raw_cb) { + pv.raw_cb(mut pv, fd, events) + return + } } } diff --git a/vlib/picoev/socket_util.c.v b/vlib/picoev/socket_util.c.v index 5936004ac7..f90946b17c 100644 --- a/vlib/picoev/socket_util.c.v +++ b/vlib/picoev/socket_util.c.v @@ -1,7 +1,6 @@ module picoev import net -import net.conv import picohttpparser #include @@ -102,7 +101,7 @@ fn fatal_socket_error(fd int) bool { // listen creates a listening tcp socket and returns its file descriptor fn listen(config Config) int { // not using the `net` modules sockets, because not all socket options are defined - fd := C.socket(net.AddrFamily.ip, net.SocketType.tcp, 0) + fd := C.socket(config.family, net.SocketType.tcp, 0) assert fd != -1 $if trace_fd ? { @@ -124,20 +123,18 @@ fn listen(config Config) int { } // addr settings + saddr := '${config.host}:${config.port}' + addrs := net.resolve_addrs(saddr, config.family, .tcp) or { panic(err) } + addr := addrs[0] + alen := addr.len() - sin_port := conv.hton16(u16(config.port)) - sin_addr := conv.hton32(u32(C.INADDR_ANY)) - mut addr := C.sockaddr_in{ - sin_family: u8(C.AF_INET) - sin_port: sin_port - sin_addr: sin_addr + net.socket_error_message(C.bind(fd, voidptr(&addr), alen), 'binding to ${saddr} failed') or { + panic(err) + } + net.socket_error_message(C.listen(fd, C.SOMAXCONN), 'listening on ${saddr} with maximum backlog pending queue of ${C.SOMAXCONN}, failed') or { + panic(err) } - size := sizeof(C.sockaddr_in) - bind_res := C.bind(fd, voidptr(unsafe { &net.Addr(&addr) }), size) - assert bind_res == 0 - listen_res := C.listen(fd, C.SOMAXCONN) - assert listen_res == 0 setup_sock(fd) or { config.err_cb(config.user_data, picohttpparser.Request{}, mut &picohttpparser.Response{}, err) diff --git a/vlib/x/vweb/README.md b/vlib/x/vweb/README.md new file mode 100644 index 0000000000..84cc83d0f3 --- /dev/null +++ b/vlib/x/vweb/README.md @@ -0,0 +1,833 @@ +# vweb - the V Web Server + +A simple yet powerful web server with built-in routing, parameter handling, templating, and other +features. + +## Features + +- **Very fast** performance of C on the web. +- **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. +- **Middleware** functionality similair to other big frameworks. +- **Controllers** to split up your apps logic. + +## Quick Start + +Run your vweb app with a live reload via `v -d vweb_livereload watch run .` + +Now modifying any file in your web app (whether it's a .v file with the backend logic +or a compiled .html template file) will result in an instant refresh of your app +in the browser. No need to quit the app, rebuild it, and refresh the page in the browser! + +## Deploying vweb apps + +All the code, including HTML templates, is in one binary file. That's all you need to deploy. +Use the `-prod` flag when building for production. + +## Getting Started + +To start, you must import the module `x.vweb` and define a structure which will +represent your app and a structure which will represent the context of a request. +These structures must be declared with the `pub` keyword. + +**Example:** +```v +module main + +import x.vweb + +pub struct User { +pub mut: + name string + id int +} + +// Our context struct must embed `vweb.Context`! +pub struct Context { + vweb.Context +pub mut: + // In the context struct we store data that could be different + // for each request. Like a User struct or a session id + user User + session_id string +} + +pub struct App { +pub: + // In the app struct we store data that should be accessible by all endpoints. + // For example a database or configuration values. + secret_key string +} + +// This is how endpoints are defined in vweb. This is the index route +pub fn (app &App) index(mut ctx Context) vweb.Result { + return ctx.text('Hello V! The secret key is "${app.secret_key}"') +} + +fn main() { + mut app := &App{ + secret_key: 'secret' + } + // Pass the App and context type and start the web server on port 8080 + vweb.run[App, Context](mut app, 8080) +} +``` + +You can use the `App` struct for data you want to keep during the lifetime of your program, +or for data that you want to share between different routes. + +A new `Context` struct is created every time a request is received, +so it can contain different data for each request. + +## Defining endpoints + +To add endpoints to your web server, you must 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 parameter of your Context type and a response of the type `vweb.Result`. + +**Example:** +```v ignore +// This endpoint can be accessed via http://server:port/hello +pub fn (app &App) hello(mut ctx Context) vweb.Result { + return ctx.text('Hello') +} + +// This endpoint can be accessed via http://server:port/foo +@['/foo'] +pub fn (app &App) world(mut ctx Context) vweb.Result { + return ctx.text('World') +} +``` + +### HTTP verbs + +To use any HTTP verbs (or methods, as they are properly called), +such as `@[post]`, `@[get]`, `@[put]`, `@[patch]` or `@[delete]` +you can simply add the attribute before the function definition. + +**Example:** + +```v ignore +// only GET requests to http://server:port/world are handled by this method +@[get] +pub fn (app &App) world(mut ctx Context) vweb.Result { + return ctx.text('World') +} + +// only POST requests to http://server:port/product/create are handled by this method +@['/product/create'; post] +pub fn (app &App) create_product(mut ctx Context) vweb.Result { + return ctx.text('product') +} +``` + +By default endpoints are marked as GET requests only. It is also possible to +add multiple HTTP verbs per endpoint. + +**Example:** + +```v ignore +// only GET and POST requests to http://server:port/login are handled by this method +@['/login'; get; post] +pub fn (app &App) login(mut ctx Context) vweb.Result { + if ctx.req.method == .get { + // show the login page on a GET request + return ctx.html('

Login page

todo: make form

') + } else { + // request method is POST + password := ctx.form['password'] + // validate password length + if password.len < 12 { + return ctx.text('password is too weak!') + } else { + // redirect to the profile page + return ctx.redirect('/profile') + } + } +} +``` + +### Routes with Parameters + +Parameters are passed directly to an endpoint route using the colon sign `:`. The route +parameters are passed as arguments. V will cast the parameter to any of V's primitive types +(`string`, `int` etc,). + +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 +// V will pass the parameter 'user' as a string + vvvv +@['/hello/:user'] vvvv +pub fn (app &App) hello_user(mut ctx Context, user string) vweb.Result { + return ctx.text('Hello ${user}') +} + +// V will pass the parameter 'id' as an int + vv +@['/document/:id'] vv +pub fn (app &App) get_document(mut ctx Context, id int) vweb.Result { + return ctx.text('Hello ${user}') +} +``` + +If we visit http://localhost:port/hello/vaesel we would see the text `Hello vaesel`. + +### Routes with Parameter Arrays + +If you want multiple parameters in your route and if you want to parse the parameters +yourself, or you want a wildcard route, you can add `...` after the `:` and name, +e.g. `@['/:path...']`. + +This will match all routes after `'/'`. For example the url `/path/to/test` would give +`path = '/path/to/test'`. + +```v ignore + vvv +@['/:path...'] vvvv +pub fn (app &App) wildcard(mut ctx Context, path string) vweb.Result { + return ctx.text('URL path = "${path}"') +} +``` + +### Query, Form and Files + +You have direct access to query values by accessing the `query` field on your context struct. +You are also able to access any formdata or files that were sent +with the request with the fields `.form` and `.files` respectively. + +In the following example, visiting http://localhost:port/user?name=Vweb we +will see the text `Hello Vweb!`. And if we access the route without the `name` parameter, +http://localhost:port/user, we will see the text `no user was found`, + +**Example:** +```v ignore +@['/user'; get] +pub fn (app &App) get_user_by_id(mut ctx Context) vweb.Result { + user_name := ctx.query['name'] or { + // we can exit early and send a different response if no `name` parameter was passed + return ctx.text('no user was found') + } + + return ctx.text('Hello ${user_name}!') +} +``` + +### Host +To restrict an endpoint to a specific host, you can use the `host` attribute +followed by a colon `:` and the host name. You can test the Host feature locally +by adding a host to the "hosts" file of your device. + +**Example:** + +```v ignore +@['/'; host: 'example.com'] +pub fn (app &App) hello_web(mut ctx Context) vweb.Result { + return app.text('Hello World') +} + +@['/'; host: 'api.example.org'] +pub fn (app &App) hello_api(mut ctx Context) vweb.Result { + return ctx.text('Hello API') +} + +// define the handler without a host attribute last if you have conflicting paths. +@['/'] +pub fn (app &App) hello_others(mut ctx Context) vweb.Result { + return ctx.text('Hello Others') +} +``` + +You can also [create a controller](#controller-with-hostname) to handle all requests from a specific +host in one app struct. + +### Route Matching Order + +Vweb will match routes in the order that you define endpoints. + +**Example:** +```v ignore +@['/:path'] +pub fn (app &App) with_parameter(mut ctx Context, path string) vweb.Result { + return ctx.text('from with_parameter, path: "${path}"') +} + +@['/normal'] +pub fn (app &App) normal(mut ctx Context) vweb.Result { + return ctx.text('from normal') +} +``` + +In this example we defined an endpoint with a parameter first. If we access our app +on the url http://localhost:port/normal we will not see `from normal`, but +`from with_parameter, path: "normal"`. + +### Custom not found page + +You can implement a `not_found` endpoint that is called when a request is made and no +matching route is found to replace the default HTTP 404 not found page. This route +has to be defined on our Context struct. + +**Example:** + +``` v ignore +pub fn (mut ctx Context) not_found() vweb.Result { + // set HTTP status 404 + ctx.res.set_status(.not_found) + return ctx.html('

Page not found!

') +} +``` + +## Static files + +Vweb also provides a way of handling static files. We can mount a folder at the root +of our web app, or at a custom route. To start using static files we have to embed +`vweb.StaticHandler` on our app struct. + +**Example:** + +Let's say you have the following file structure: +``` +. +├── static/ +│ ├── css/ +│ │ └── main.css +│ └── js/ +│ └── main.js +└── main.v +``` + +If we want all the documents inside the `static` sub-directory to be publicly accessible we can +use `handle_static`. + +> **Note:** +> vweb will recursively search the folder you mount; all the files inside that folder +> will be publicly available. + +*main.v* +```v +module main + +import x.vweb + +pub struct Context { + vweb.Context +} + +pub struct App { + vweb.StaticHandler +} + +fn main() { + mut app := &App{} + + app.handle_static('static', false)! + + vweb.run[App, Context](mut app, 8080) +} +``` + +If we start the app with `v run main.v` we can access our `main.css` file at +http://localhost:8080/static/css/main.css + +### Mounting folders at specific locations + +In the previous example the folder `static` was mounted at `/static`. We could also choose +to mount the static folder at the root of our app: everything inside the `static` folder +is available at `/`. + +**Example:** +```v ignore +// change the second argument to `true` to mount a folder at the app root +app.handle_static('static', true)! +``` +We can now access `main.css` directly at http://localhost:8080/css/main.css + +It is also possible to mount the `static` folder at a custom path. + +**Example:** +```v ignore +// mount the folder 'static' at path '/public', the path has to start with '/' +app.mount_static_folder_at('static', '/public') +``` + +If we run our app the `main.css` file is available at http://localhost:8080/public/main.css + +### Dealing with MIME types + +By default vweb will map the extension of a file to a MIME type. If any of your static file's +extensions do not have a default MIME type in vweb, vweb will throw an error and you +have to add your MIME type to `.static_mime_types` yourself. + +**Example:** + +Let's say you have the following file structure: +``` +. +├── static/ +│ └── file.what +└── main.v +``` +```v ignore +app.handle_static('static', true)! +``` +This code will throw an error, because vweb has no default MIME type for a `.what` file extension. +``` +unkown MIME type for file extension ".what" +``` +To fix this we have to provide a MIME type for the `.what` file extension: +```v ignore +app.static_mime_types['.what'] = 'txt/plain' +app.handle_static('static', true)! +``` + + +## Middleware + +Middleware in web development is (loosely defined) a hidden layer that sits between +what a user requests (the HTTP Request) and what a user sees (the HTTP Response). +We can use this middleware layer to provide "hidden" functionality to our apps endpoints. + +To use vweb's middleware we have to embed `vweb.Middleware` on our app struct and provide +the type of which context struct should be used. + +**Example:** +```v ignore +pub struct App { + vweb.Middleware[Context] +} +``` + +### Use case + +We could, for example, get the cookies for an HTTP request and check if the user has already +accepted our cookie policy. Let's modify our Context struct to store whether the user has +accepted our policy or not. + +**Example:** +```v ignore +pub struct Context { + vweb.Context +pub mut: + has_accepted_cookies bool +} +``` + +In vweb middleware functions take a `mut` parameter with the type of your context struct +and must return `bool`. We have full access to modify our Context struct! + +The return value indicates to vweb whether it can continue or has to stop. If we send a +response to the client in a middleware function vweb has to stop, so we return `false`. + +**Example:** +```v ignore +pub fn check_cookie_policy(mut ctx Context) bool { + // get the cookie + cookie_value := ctx.get_cookie('accepted_cookies') or { '' } + // check if the cookie has been set + if cookie_value == 'true' { + ctx.has_accepted_cookies = true + } + // we don't send a response, so we must return true + return true +} +``` + +We can check this value in an endpoint and return a different response. + +**Example:** +```v ignore +@['/only-cookies'] +pub fn (app &App) only_cookie_route(mut ctx Context) vweb.Result { + if ctx.has_accepted_cookies { + return ctx.text('Welcome!') + } else { + return ctx.text('You must accept the cookie policy!') + } +} +``` + +There is one thing left for our middleware to work: we have to register our `only_cookie_route` +function as middleware for our app. We must do this after the app is created and before the +app is started. + +**Example:** +```v ignore +fn main() { + mut app := &App{} + + // register middleware for all routes + app.use(handler: only_cookie_route) + + // Pass the App and context type and start the web server on port 8080 + vweb.run[App, Context](mut app, 8080) +} +``` + +### Types of middleware + +In the previous example we used so called "global" middleware. This type of middleware +applies to every endpoint defined on our app struct; global. It is also possible +to register middleware for only a certain route(s). + +**Example:** +```v ignore +// register middleware only for the route '/auth' +app.route_use('/auth', handler: auth_middleware) +// register middleware only for the route '/documents/' with a parameter +// e.g. '/documents/5' +app.route_use('/documents/:id') +// register middleware with a parameter array. The middleware will be registered +// for all routes that start with '/user/' e.g. '/user/profile/update' +app.route_use('/user/:path...') +``` + +### Evaluation moment + +By default the registered middleware functions are executed *before* a method on your +app struct is called. You can also change this behaviour to execute middleware functions +*after* a method on your app struct is called, but before the response is sent! + +**Example:** +```v ignore +pub fn modify_headers(mut ctx Context) bool { + // add Content-Language: 'en-US' header to each response + ctx.res.header.add(.content_language, 'en-US') + return true +} +``` +```v ignore +app.use(handler: modify_headers, after: true) +``` + +#### When to use which type + +You could use "before" middleware to check and modify the HTTP request and you could use +"after" middleware to validate the HTTP response that will be sent or do some cleanup. + +Anything you can do in "before" middleware, you can do in "after" middleware. + +### Evaluation order + +Vweb will handle requests in the following order: + +1. Execute global "before" middleware +2. Execute "before" middleware that matches the requested route +3. Execute the endpoint handler on your app struct +4. Execute global "after" middleware +5. Execute "after" middleware that matches the requested route + +In each step, except for step `3`, vweb will evaluate the middleware in the order that +they are registered; when you call `app.use` or `app.route_use`. + +### Early exit + +If any middleware sends a response (and thus must return `false`) vweb will not execute any +other middleware, or the endpoint method, and immediately send the response. + +**Example:** +```v ignore +pub fn early_exit(mut ctx Context) bool { + ctx.text('early exit') + // we send a response from middleware, so we have to return false + return false +} + +pub fn logger(mut ctx Context) bool { + println('received request for "${ctx.req.url}"') + return true +} +``` +```v ignore +app.use(handler: early_exit) +app.use(handler: logger) +``` + +Because we register `early_exit` before `logger` our logging middleware will never be executed! + +## Controllers + +Controllers can be used to split up your app logic so you are able to have one struct +per "route group". E.g. a struct `Admin` for urls starting with `'/admin'` and a struct `Foo` +for urls starting with `'/foo'`. + +To use controllers we have to embed `vweb.Controller` on +our app struct and when we register a controller we also have to specify +what the type of the context struct will be. That means that is is possible +to have a different context struct for each controller and the main app struct. + +**Example:** +```v +module main + +import x.vweb + +pub struct Context { + vweb.Context +} + +pub struct App { + vweb.Controller +} + +// this endpoint will be available at '/' +pub fn (app &App) index(mut ctx Context) vweb.Result { + return ctx.text('from app') +} + +pub struct Admin {} + +// this endpoint will be available at '/admin/' +pub fn (app &Admin) index(mut ctx Context) vweb.Result { + return ctx.text('from admin') +} + +pub struct Foo {} + +// this endpoint will be available at '/foo/' +pub fn (app &Foo) index(mut ctx Context) vweb.Result { + return ctx.text('from foo') +} + +fn main() { + mut app := &App{} + + // register the controllers the same way as how we start a vweb app + mut admin_app := &Admin{} + app.register_controller[Admin, Context]('/admin', mut admin_app)! + + mut foo_app := &Foo{} + app.register_controller[Foo, Context]('/foo', mut foo_app)! + + vweb.run[App, Context](mut app, 8080) +} +``` + +You can do everything with a controller struct as with a regular `App` struct. +Register middleware, add static files and you can even register other controllers! + +### Routing + +Any route inside a controller struct is treated as a relative route to its controller namespace. + +```v ignore +@['/path'] +pub fn (app &Admin) path(mut ctx Context) vweb.Result { + return ctx.text('Admin') +} +``` +When we registerted the controller with +`app.register_controller[Admin, Context]('/admin', mut admin_app)!` +we told vweb that the namespace of that controller is `'/admin'` so in this example we would +see the text "Admin" if we navigate to the url `'/admin/path'`. + +Vweb doesn't support duplicate routes, so if we add the following +route to the example the code will produce an error. + +```v ignore +@['/admin/path'] +pub fn (app &App) admin_path(mut ctx Context) vweb.Result { + return ctx.text('Admin overwrite') +} +``` +There will be an error, because the controller `Admin` handles all routes starting with +`'/admin'`: the endpoint `admin_path` is unreachable. + +### Controller with hostname + +You can also set a host for a controller. All requests coming to that host will be handled +by the controller. + +**Example:** +```v ignore +struct Example {} + +// You can only access this route at example.com: http://example.com/ +pub fn (app &Example) index(mut ctx Context) vweb.Result { + return ctx.text('Example') +} +``` +```v ignore +mut example_app := &Example{} +// set the controllers hostname to 'example.com' and handle all routes starting with '/', +// we handle requests with any route to 'example.com' +app.register_controller[Example, Context]('example.com', '/', mut example_app)! +``` + +## Context Methods + +vweb has a number of utility methods that make it easier to handle requests and send responses. +These methods are available on `vweb.Context` and directly on your own context struct if you +embed `vweb.Context`. Below are some of te most used methods, look at the +[standard library documentation](https://modules.vlang.io/) to see them all. + +### Request methods + +You can directly access the HTTP request on the `.req` field. + +#### Get request headers + +**Example:** +```v ignore +pub fn (app &App) index(mut ctx Context) vweb.Result { + content_length := ctx.get_header(.content_length) or { '0' } + // get custom header + custom_header := ctx.get_custom_header('X-HEADER') or { '' } + // ... +} +``` + +#### Get a cookie + +**Example:** +```v ignore +pub fn (app &App) index(mut ctx Context) vweb.Result { + cookie_val := ctx.get_cookie('token') or { '' } + // ... +} +``` + +### Response methods + +You can directly modify the HTTP response by changing the `res` field, +which is of the type `http.Response`. + +#### Send response with different MIME types + +```v ignore +// send response HTTP_OK with content-type `text/html` +ctx.html('

Hello world!

') +// send response HTTP_OK with content-type `text/plain` +ctx.text('Hello world!') +// stringify the object and send response HTTP_OK with content-type `application/json` +ctx.json(User{ + name: 'test' + age: 20 +}) +``` + +#### Sending files + +**Example:** +```v ignore +pub fn (app &App) file_response(mut ctx Context) vweb.Result { + // send the file 'image.png' in folder 'data' to the user + return ctx.file('data/image.png') +} +``` + +#### Set response headers + +**Example:** +```v ignore +pub fn (app &App) index(mut ctx Context) vweb.Result { + ctx.set_header(.accept, 'text/html') + // set custom header + ctx.set_custom_header('X-HEADER', 'my-value')! + // ... +} +``` + +#### Set a cookie + +**Example:** +```v ignore +pub fn (app &App) index(mut ctx Context) vweb.Result { + ctx.set_cookie(http.Cookie{ + name: 'token' + value: 'true' + path: '/' + secure: true + http_only: true + }) + // ... +} +``` + +#### Redirect + +**Example:** +```v ignore +pub fn (app &App) index(mut ctx Context) vweb.Result { + token := ctx.get_cookie('token') or { '' } + if token == '' { + // redirect the user to '/login' if the 'token' cookie is not set + return ctx.redirect('/login') + } else { + return ctx.text('Welcome!') + } +} +``` + +#### Sending error responses + +**Example:** +```v ignore +pub fn (app &App) login(mut ctx Context) vweb.Result { + if username := ctx.form['username'] { + return ctx.text('Hello "${username}"') + } else { + // send an HTTP 400 Bad Request response with a message + return ctx.request_error('missing form value "username"') + } +} +``` + +You can also use `ctx.server_error(msg string)` to send an HTTP 500 internal server +error with a message. + +## Advanced usage + +If you need more controll over the TCP connection with a client, for example when +you want to keep the connection open. You can call `ctx.takeover_conn`. + +When this function is called you are free to do anything you want with the TCP +connection and vweb will not interfere. This means that we are responsible for +sending a response over the connection and closing it. + +**Example:** +```v +module main + +import net +import time +import x.vweb + +pub struct Context { + vweb.Context +} + +pub struct App {} + +pub fn (app &App) index(mut ctx Context) vweb.Result { + return ctx.text('hello!') +} + +@['/long'] +pub fn (app &App) long_response(mut ctx Context) vweb.Result { + // use spawn to handle the connection in another thread + // if we don't the whole web server will block for 10 seconds, + // since vweb is singlethreaded + spawn handle_connection(mut ctx.conn) + return ctx.takeover_conn() +} + +fn handle_connection(mut conn net.TcpConn) { + defer { + conn.close() or {} + } + // block for 10 second + time.sleep(time.second * 10) + conn.write_string('HTTP/1.1 200 OK\r\nContent-type: text/html\r\nContent-length: 15\r\n\r\nHello takeover!') or {} +} + +fn main() { + mut app := &App{} + vweb.run[App, Context](mut app, 8080) +} +``` \ No newline at end of file diff --git a/vlib/x/vweb/context.v b/vlib/x/vweb/context.v new file mode 100644 index 0000000000..c92a8c6bc3 --- /dev/null +++ b/vlib/x/vweb/context.v @@ -0,0 +1,287 @@ +module vweb + +import json +import net +import net.http +import os + +enum ContextReturnType { + normal + file +} + +// The Context struct represents the Context which holds the HTTP request and response. +// It has fields for the query, form, files and methods for handling the request and response +pub struct Context { +mut: + // vweb wil try to infer the content type base on file extension, + // and if `content_type` is not empty the `Content-Type` header will always be + // set to this value + content_type string + // done is set to true when a response can be sent over `conn` + done bool + // if true the response should not be sent and the connection should be closed + // manually. + takeover bool + // how the http response should be handled by vweb's backend + return_type ContextReturnType = .normal + return_file string +pub: + // TODO: move this to `handle_request` + // time.ticks() from start of vweb connection handle. + // You can use it to determine how much time is spent on your request. + page_gen_start i64 + req http.Request +pub mut: + custom_mime_types map[string]string + // TCP connection to client. Only for advanced usage! + conn &net.TcpConn = unsafe { nil } + // Map containing query params for the route. + // http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' } + query map[string]string + // Multipart-form fields. + form map[string]string + // Files from multipart-form. + files map[string][]http.FileData + res http.Response + // use form_error to pass errors from the context to your frontend + form_error string + livereload_poll_interval_ms int = 250 +} + +// returns the request header data from the key +pub fn (ctx &Context) get_header(key http.CommonHeader) !string { + return ctx.req.header.get(key)! +} + +// returns the request header data from the key +pub fn (ctx &Context) get_custom_header(key string) !string { + return ctx.req.header.get_custom(key)! +} + +// set a header on the response object +pub fn (mut ctx Context) set_header(key http.CommonHeader, value string) { + ctx.res.header.set(key, value) +} + +// set a custom header on the response object +pub fn (mut ctx Context) set_custom_header(key string, value string) ! { + ctx.res.header.set_custom(key, value)! +} + +// send_response_to_client finalizes the response headers and sets Content-Type to `mimetype` +// and the response body to `response` +pub fn (mut ctx Context) send_response_to_client(mimetype string, response string) Result { + if ctx.done && !ctx.takeover { + eprintln('[vweb] a response cannot be sent twice over one connection') + return Result{} + } + // ctx.done is only set in this function, so in order to sent a response over the connection + // this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly. + ctx.done = true + ctx.res.body = response + $if vweb_livereload ? { + if mimetype == 'text/html' { + ctx.res.body = response.replace('', '\n') + } + } + + // set Content-Type and Content-Length headers + mut custom_mimetype := if ctx.content_type.len == 0 { mimetype } else { ctx.content_type } + ctx.res.header.set(.content_type, custom_mimetype) + ctx.res.header.set(.content_length, ctx.res.body.len.str()) + // send vweb's closing headers + ctx.res.header.set(.server, 'VWeb') + ctx.res.header.set(.connection, 'close') + // set the http version + ctx.res.set_version(.v1_1) + if ctx.res.status_code == 0 { + ctx.res.set_status(.ok) + } + + if ctx.takeover { + fast_send_resp(mut ctx.conn, ctx.res) or {} + } + // result is send in `vweb.v`, `handle_route` + return Result{} +} + +// Response HTTP_OK with s as payload with content-type `text/html` +pub fn (mut ctx Context) html(s string) Result { + return ctx.send_response_to_client('text/html', s) +} + +// Response HTTP_OK with s as payload with content-type `text/plain` +pub fn (mut ctx Context) text(s string) Result { + return ctx.send_response_to_client('text/plain', s) +} + +// Response HTTP_OK with j as payload with content-type `application/json` +pub fn (mut ctx Context) json[T](j T) Result { + json_s := json.encode(j) + return ctx.send_response_to_client('application/json', json_s) +} + +// Response HTTP_OK with a pretty-printed JSON result +pub fn (mut ctx Context) json_pretty[T](j T) Result { + json_s := json.encode_pretty(j) + return ctx.send_response_to_client('application/json', json_s) +} + +// TODO - test + turn read_file into streaming +// Response HTTP_OK with file as payload +pub fn (mut ctx Context) file(file_path string) Result { + if !os.exists(file_path) { + eprintln('[vweb] file "${file_path}" does not exist') + return ctx.not_found() + } + + ext := os.file_ext(file_path) + + mut content_type := ctx.content_type + if content_type.len == 0 { + if ct := ctx.custom_mime_types[ext] { + content_type = ct + } else { + content_type = mime_types[ext] + } + } + + if content_type.len == 0 { + eprintln('[vweb] no MIME type found for extension "${ext}"') + return ctx.server_error('') + } + + return ctx.send_file(content_type, file_path) +} + +fn (mut ctx Context) send_file(content_type string, file_path string) Result { + mut file := os.open(file_path) or { + eprint('[vweb] error while trying to open file: ${err.msg()}') + ctx.res.set_status(.not_found) + return ctx.text('resource does not exist') + } + + // seek from file end to get the file size + file.seek(0, .end) or { + eprintln('[vweb] error while trying to read file: ${err.msg()}') + return ctx.server_error('could not read resource') + } + file_size := file.tell() or { + eprintln('[vweb] error while trying to read file: ${err.msg()}') + return ctx.server_error('could not read resource') + } + file.close() + + // optimization: use max_read on purpose instead of max_write to take into account + // the HTTP header size and the fact that it's not likely that the socket/OS + // is able to write 8KB at once under load. + if file_size < max_read || ctx.takeover { + // it's a small file so we can send the response directly + data := os.read_file(file_path) or { + eprintln('[vweb] error while trying to read file: ${err.msg()}') + return ctx.server_error('could not read resource') + } + return ctx.send_response_to_client(content_type, data) + } else { + ctx.return_type = .file + ctx.return_file = file_path + + // set response headers + ctx.send_response_to_client(content_type, '') + ctx.res.header.set(.content_length, file_size.str()) + return Result{} + } +} + +// Response HTTP_OK with s as payload +pub fn (mut ctx Context) ok(s string) Result { + mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type } + return ctx.send_response_to_client(mime, s) +} + +// send an error 400 with a message +pub fn (mut ctx Context) request_error(msg string) Result { + ctx.res.set_status(.bad_request) + return ctx.send_response_to_client('text/plain', msg) +} + +// send an error 500 with a message +pub fn (mut ctx Context) server_error(msg string) Result { + ctx.res.set_status(.internal_server_error) + return ctx.send_response_to_client('text/plain', msg) +} + +// Redirect to an url +pub fn (mut ctx Context) redirect(url string) Result { + ctx.res.set_status(.found) + ctx.res.header.add(.location, url) + return ctx.send_response_to_client('text/plain', '302 Found') +} + +// before_request is always the first function that is executed and acts as middleware +pub fn (mut ctx Context) before_request() Result { + return Result{} +} + +// returns a HTTP 404 response +pub fn (mut ctx Context) not_found() Result { + ctx.res.set_status(.not_found) + return ctx.send_response_to_client('text/plain', '404 Not Found') +} + +// Gets a cookie by a key +pub fn (ctx &Context) get_cookie(key string) ?string { + if cookie := ctx.req.cookie(key) { + return cookie.value + } else { + return none + } +} + +// Sets a cookie +pub fn (mut ctx Context) set_cookie(cookie http.Cookie) { + cookie_raw := cookie.str() + if cookie_raw == '' { + eprintln('[vweb] error setting cookie: name of cookie is invalid.\n${cookie}') + return + } + ctx.res.header.add(.set_cookie, cookie_raw) +} + +// set_content_type sets the Content-Type header to `mime` +pub fn (mut ctx Context) set_content_type(mime string) { + ctx.content_type = mime +} + +// takeover_conn prevents vweb from automatically sending a response and closing +// the connection. You are responsible for closing the connection. +// In takeover mode if you call a Context method the response will be directly +// send over the connetion and you can send multiple responses. +// This function is usefull when you want to keep the connection alive and/or +// send multiple responses. Like with the SSE. +pub fn (mut ctx Context) takeover_conn() Result { + ctx.takeover = true + return Result{} +} + +// user_agent returns the user-agent header for the current client +pub fn (ctx &Context) user_agent() string { + return ctx.req.header.get(.user_agent) or { '' } +} + +// Returns the ip address from the current user +pub fn (ctx &Context) ip() string { + mut ip := ctx.req.header.get(.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 +} diff --git a/vlib/x/vweb/controller.v b/vlib/x/vweb/controller.v new file mode 100644 index 0000000000..0141f077f5 --- /dev/null +++ b/vlib/x/vweb/controller.v @@ -0,0 +1,111 @@ +module vweb + +import net.urllib + +type ControllerHandler = fn (ctx Context, mut url urllib.URL, host string) Context + +pub struct ControllerPath { +pub: + path string + handler ControllerHandler = unsafe { nil } +pub mut: + host string +} + +interface ControllerInterface { + controllers []&ControllerPath +} + +pub struct Controller { +pub mut: + controllers []&ControllerPath +} + +// register_controller adds a new Controller to your app +pub fn (mut c Controller) register_controller[A, X](path string, mut global_app A) ! { + c.controllers << controller[A, X](path, mut global_app)! +} + +// controller generates a new Controller for the main app +pub fn controller[A, X](path string, mut global_app A) !&ControllerPath { + routes := generate_routes[A, X](global_app) or { panic(err.msg()) } + controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)! + + // generate struct with closure so the generic type is encapsulated in the closure + // no need to type `ControllerHandler` as generic since it's not needed for closures + return &ControllerPath{ + path: path + handler: fn [mut global_app, path, routes, controllers_sorted] [A, X](ctx Context, mut url urllib.URL, host string) Context { + // transform the url + url.path = url.path.all_after_first(path) + + // match controller paths + $if A is ControllerInterface { + if completed_context := handle_controllers[X](controllers_sorted, ctx, mut + url, host) + { + return completed_context + } + } + + // create a new user context and pass the vweb's context + mut user_context := X{} + user_context.Context = ctx + + handle_route[A, X](mut global_app, mut user_context, url, host, &routes) + return user_context.Context + } + } +} + +// register_controller adds a new Controller to your app +pub fn (mut c Controller) register_host_controller[A, X](host string, path string, mut global_app A) ! { + c.controllers << controller_host[A, X](host, path, mut global_app)! +} + +// controller_host generates a controller which only handles incoming requests from the `host` domain +pub fn controller_host[A, X](host string, path string, mut global_app A) &ControllerPath { + mut ctrl := controller[A, X](path, mut global_app) + ctrl.host = host + return ctrl +} + +fn check_duplicate_routes_in_controllers[T](global_app &T, routes map[string]Route) ![]&ControllerPath { + mut controllers_sorted := []&ControllerPath{} + $if T is ControllerInterface { + mut paths := []string{} + controllers_sorted = global_app.controllers.clone() + controllers_sorted.sort(a.path.len > b.path.len) + for controller in controllers_sorted { + if controller.host == '' { + if controller.path in paths { + return error('conflicting paths: duplicate controller handling the route "${controller.path}"') + } + paths << controller.path + } + } + for method_name, route in routes { + for controller_path in paths { + if route.path.starts_with(controller_path) { + return error('conflicting paths: method "${method_name}" with route "${route.path}" should be handled by the Controller of path "${controller_path}"') + } + } + } + } + return controllers_sorted +} + +fn handle_controllers[X](controllers []&ControllerPath, ctx Context, mut url urllib.URL, host string) ?Context { + for controller in controllers { + // skip controller if the hosts don't match + if controller.host != '' && host != controller.host { + continue + } + if url.path.len >= controller.path.len && url.path.starts_with(controller.path) { + // pass route handling to the controller + return controller.handler(ctx, mut url, host) + } + } + + return none +} diff --git a/vlib/x/vweb/middleware.v b/vlib/x/vweb/middleware.v new file mode 100644 index 0000000000..55ebbca499 --- /dev/null +++ b/vlib/x/vweb/middleware.v @@ -0,0 +1,171 @@ +module vweb + +import compress.gzip + +pub type MiddlewareHandler[T] = fn (mut T) bool + +// TODO: get rid of this `voidptr` interface check when generic embedded +// interfaces work properly, related: #19968 +interface MiddlewareApp { +mut: + global_handlers []voidptr + global_handlers_after []voidptr + route_handlers []RouteMiddleware + route_handlers_after []RouteMiddleware +} + +struct RouteMiddleware { + url_parts []string + handler voidptr +} + +pub struct Middleware[T] { +mut: + global_handlers []voidptr + global_handlers_after []voidptr + route_handlers []RouteMiddleware + route_handlers_after []RouteMiddleware +} + +@[params] +pub struct MiddlewareOptions[T] { + handler fn (mut ctx T) bool @[required] + after bool +} + +// string representation of Middleware +pub fn (m &Middleware[T]) str() string { + return 'vweb.Middleware[${T.name}]{ + global_handlers: [${m.global_handlers.len}] + global_handlers_after: [${m.global_handlers_after.len}] + route_handlers: [${m.route_handlers.len}] + route_handlers_after: [${m.route_handlers_after.len}] + }' +} + +// use registers a global middleware handler +pub fn (mut m Middleware[T]) use(options MiddlewareOptions[T]) { + if options.after { + m.global_handlers_after << voidptr(options.handler) + } else { + m.global_handlers << voidptr(options.handler) + } +} + +// route_use registers a middlware handler for a specific route(s) +pub fn (mut m Middleware[T]) route_use(route string, options MiddlewareOptions[T]) { + middleware := RouteMiddleware{ + url_parts: route.split('/').filter(it != '') + handler: voidptr(options.handler) + } + + if options.after { + m.route_handlers_after << middleware + } else { + m.route_handlers << middleware + } +} + +fn (m &Middleware[T]) get_handlers_for_route(route_path string) []voidptr { + mut fns := []voidptr{} + route_parts := route_path.split('/').filter(it != '') + + for handler in m.route_handlers { + if _ := route_matches(route_parts, handler.url_parts) { + fns << handler.handler + } else if handler.url_parts.len == 0 && route_path == '/index' { + fns << handler.handler + } + } + + return fns +} + +fn (m &Middleware[T]) get_handlers_for_route_after(route_path string) []voidptr { + mut fns := []voidptr{} + route_parts := route_path.split('/').filter(it != '') + + for handler in m.route_handlers_after { + if _ := route_matches(route_parts, handler.url_parts) { + fns << handler.handler + } else if handler.url_parts.len == 0 && route_path == '/index' { + fns << handler.handler + } + } + + return fns +} + +fn (m &Middleware[T]) get_global_handlers() []voidptr { + return m.global_handlers +} + +fn (m &Middleware[T]) get_global_handlers_after() []voidptr { + return m.global_handlers_after +} + +fn validate_middleware[T](mut ctx T, raw_handlers []voidptr) bool { + for handler in raw_handlers { + func := MiddlewareHandler[T](handler) + if func(mut ctx) == false { + return false + } + } + + return true +} + +// encode_gzip adds gzip encoding to the HTTP Response body. +// This middleware does not encode files, if you return `ctx.file()`. +// Register this middleware as last! +// Example: app.use(vweb.encode_gzip[Context]()) +pub fn encode_gzip[T]() MiddlewareOptions[T] { + return MiddlewareOptions[T]{ + after: true + handler: fn [T](mut ctx T) bool { + // TODO: compress file in streaming manner, or precompress them? + if ctx.return_type == .file { + return true + } + // first try compressions, because if it fails we can still send a response + // before taking over the connection + compressed := gzip.compress(ctx.res.body.bytes()) or { + eprintln('[vweb] error while compressing with gzip: ${err.msg()}') + return true + } + // enables us to have full controll over what response is send over the connection + // and how. + ctx.takeover_conn() + + // set HTTP headers for gzip + ctx.res.header.add(.content_encoding, 'gzip') + ctx.res.header.set(.vary, 'Accept-Encoding') + ctx.res.header.set(.content_length, compressed.len.str()) + + fast_send_resp_header(mut ctx.Context.conn, ctx.res) or {} + ctx.Context.conn.write_ptr(&u8(compressed.data), compressed.len) or {} + ctx.Context.conn.close() or {} + + return false + } + } +} + +// decode_gzip decodes the body of a gzip'ed HTTP request. +// Register this middleware before you do anything with the request body! +// Example: app.use(vweb.decode_gzip[Context]()) +pub fn decode_gzip[T]() MiddlewareOptions[T] { + return MiddlewareOptions[T]{ + handler: fn [T](mut ctx T) bool { + if encoding := ctx.res.header.get(.content_encoding) { + if encoding == 'gzip' { + decompressed := gzip.decompress(ctx.req.body.bytes()) or { + ctx.request_error('invalid gzip encoding') + return false + } + ctx.req.body = decompressed.bytestr() + } + } + } + } +} diff --git a/vlib/x/vweb/parse.v b/vlib/x/vweb/parse.v new file mode 100644 index 0000000000..5c7d841055 --- /dev/null +++ b/vlib/x/vweb/parse.v @@ -0,0 +1,93 @@ +module vweb + +import net.urllib +import net.http + +// Parsing function attributes for methods and path. +fn parse_attrs(name string, attrs []string) !([]http.Method, string, string) { + if attrs.len == 0 { + return [http.Method.get], '/${name}', '' + } + + mut x := attrs.clone() + mut methods := []http.Method{} + mut path := '' + mut host := '' + + for i := 0; i < x.len; { + attr := x[i] + attru := attr.to_upper() + m := http.method_from_str(attru) + if attru == 'GET' || m != .get { + methods << m + x.delete(i) + continue + } + if attr.starts_with('/') { + if path != '' { + return http.MultiplePathAttributesError{} + } + path = attr + x.delete(i) + continue + } + if attr.starts_with('host:') { + host = attr.all_after('host:').trim_space() + x.delete(i) + continue + } + i++ + } + if x.len > 0 { + return http.UnexpectedExtraAttributeError{ + attributes: x + } + } + if methods.len == 0 { + methods = [http.Method.get] + } + if path == '' { + path = '/${name}' + } + // Make host lowercase for case-insensitive comparisons + return methods, path, host.to_lower() +} + +fn parse_query_from_url(url urllib.URL) map[string]string { + mut query := map[string]string{} + for qvalue in url.query().data { + query[qvalue.key] = qvalue.value + } + return query +} + +const boundary_start = 'boundary=' + +struct FileData { +pub: + filename string + content_type string + data string +} + +// TODO: fix windows files? (CLRF) issues, maybe it is in the `net` module +fn parse_form_from_request(request http.Request) !(map[string]string, map[string][]http.FileData) { + if request.method !in [http.Method.post, .put, .patch] { + return map[string]string{}, map[string][]http.FileData{} + } + ct := request.header.get(.content_type) or { '' }.split(';').map(it.trim_left(' \t')) + if 'multipart/form-data' in ct { + boundaries := ct.filter(it.starts_with(vweb.boundary_start)) + if boundaries.len != 1 { + return error('detected more that one form-data boundary') + } + boundary := boundaries[0].all_after(vweb.boundary_start) + if boundary.len > 0 && boundary[0] == `"` { + // quotes are send by our http.post_multipart_form/2: + return http.parse_multipart_form(request.data, boundary.trim('"')) + } + // Firefox and other browsers, do not use quotes around the boundary: + return http.parse_multipart_form(request.data, boundary) + } + return http.parse_form(request.data), map[string][]http.FileData{} +} diff --git a/vlib/x/vweb/route_test.v b/vlib/x/vweb/route_test.v new file mode 100644 index 0000000000..02f80528e9 --- /dev/null +++ b/vlib/x/vweb/route_test.v @@ -0,0 +1,282 @@ +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']) +} + +fn test_route_index_path() { + RoutePair{ + url: '/' + route: '/:path...' + }.test_param(['/']) + + RoutePair{ + url: '/foo/bar' + route: '/:path...' + }.test_param(['/foo/bar']) +} diff --git a/vlib/x/vweb/static_handler.v b/vlib/x/vweb/static_handler.v new file mode 100644 index 0000000000..38f44e5cb7 --- /dev/null +++ b/vlib/x/vweb/static_handler.v @@ -0,0 +1,115 @@ +module vweb + +import os + +pub interface StaticApp { +mut: + static_files map[string]string + static_mime_types map[string]string + static_hosts map[string]string +} + +// StaticHandler provides methods to handle static files in your vweb App +pub struct StaticHandler { +pub mut: + static_files map[string]string + static_mime_types map[string]string + static_hosts map[string]string +} + +// scan_static_directory recursively scans `directory_path` and returns an error if +// no valid MIME type can be found +fn (mut sh StaticHandler) scan_static_directory(directory_path string, mount_path string, host string) ! { + files := os.ls(directory_path) or { panic(err) } + if files.len > 0 { + for file in files { + full_path := os.join_path(directory_path, file) + if os.is_dir(full_path) { + sh.scan_static_directory(full_path, mount_path.trim_right('/') + '/' + file, + host)! + } else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') { + sh.host_serve_static(host, mount_path.trim_right('/') + '/' + file, full_path)! + } + } + } +} + +// handle_static is used to mark a folder (relative to the current working folder) +// as one that contains only static resources (css files, images etc). +// If `root` is set the mount path for the dir will be in '/' +// Usage: +// ```v +// os.chdir( os.executable() )? +// app.handle_static('assets', true) +// ``` +pub fn (mut sh StaticHandler) handle_static(directory_path string, root bool) !bool { + return sh.host_handle_static('', directory_path, root)! +} + +// host_handle_static is used to mark a folder (relative to the current working folder) +// as one that contains only static resources (css files, images etc). +// If `root` is set the mount path for the dir will be in '/' +// Usage: +// ```v +// os.chdir( os.executable() )? +// app.host_handle_static('localhost', 'assets', true) +// ``` +pub fn (mut sh StaticHandler) host_handle_static(host string, directory_path string, root bool) !bool { + if !os.exists(directory_path) { + return false + } + dir_path := directory_path.trim_space().trim_right('/') + mut mount_path := '' + if dir_path != '.' && os.is_dir(dir_path) && !root { + // Mount point hygiene, "./assets" => "/assets". + mount_path = '/' + dir_path.trim_left('.').trim('/') + } + sh.scan_static_directory(dir_path, mount_path, host)! + return true +} + +// mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path +// For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'), +// and you have a file /var/share/myassets/main.css . +// => That file will be available at URL: http://server/assets/main.css . +pub fn (mut sh StaticHandler) mount_static_folder_at(directory_path string, mount_path string) !bool { + return sh.host_mount_static_folder_at('', directory_path, mount_path)! +} + +// host_mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://host/mount_path +// For example: suppose you have called .host_mount_static_folder_at('localhost', '/var/share/myassets', '/assets'), +// and you have a file /var/share/myassets/main.css . +// => That file will be available at URL: http://localhost/assets/main.css . +pub fn (mut sh StaticHandler) host_mount_static_folder_at(host string, directory_path string, mount_path string) !bool { + if mount_path.len < 1 || mount_path[0] != `/` { + return error('invalid mount path! The path should start with `/`') + } else if !os.exists(directory_path) { + return error('directory "${directory_path}" does not exist') + } + + dir_path := directory_path.trim_right('/') + + trim_mount_path := mount_path.trim_left('/').trim_right('/') + sh.scan_static_directory(dir_path, '/${trim_mount_path}', host)! + return true +} + +// Serves a file static +// `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type +pub fn (mut sh StaticHandler) serve_static(url string, file_path string) ! { + sh.host_serve_static('', url, file_path)! +} + +// Serves a file static +// `url` is the access path on the site, `file_path` is the real path to the file +// `host` is the host to serve the file from +pub fn (mut sh StaticHandler) host_serve_static(host string, url string, file_path string) ! { + ext := os.file_ext(file_path) + + // Rudimentary guard against adding files not in mime_types. + if ext !in sh.static_mime_types && ext !in mime_types { + return error('unkown MIME type for file extension "${ext}". You can register your MIME type in `app.static_mime_types`') + } + sh.static_files[url] = file_path + sh.static_hosts[url] = host +} diff --git a/vlib/x/vweb/tests/controller_test.v b/vlib/x/vweb/tests/controller_test.v new file mode 100644 index 0000000000..cbb19bbfc9 --- /dev/null +++ b/vlib/x/vweb/tests/controller_test.v @@ -0,0 +1,130 @@ +import x.vweb +import time +import os +import net.http + +const port = 13006 + +const localserver = 'http://127.0.0.1:${port}' + +const exit_after = time.second * 10 + +pub struct Context { + vweb.Context +} + +pub struct App { + vweb.Controller +} + +pub fn (app &App) index(mut ctx Context) vweb.Result { + return ctx.text('from app') +} + +@['/conflict/test'] +pub fn (app &App) conflicting(mut ctx Context) vweb.Result { + return ctx.text('from conflicting') +} + +pub struct Other { + vweb.Controller +} + +pub fn (app &Other) index(mut ctx Context) vweb.Result { + return ctx.text('from other') +} + +pub struct HiddenByOther {} + +pub fn (app &HiddenByOther) index(mut ctx Context) vweb.Result { + return ctx.text('from hidden') +} + +pub struct SubController {} + +pub fn (app &SubController) index(mut ctx Context) vweb.Result { + return ctx.text('from sub') +} + +fn testsuite_begin() { + os.chdir(os.dir(@FILE))! + + spawn fn () ! { + mut sub := &SubController{} + mut other := &Other{} + other.register_controller[SubController, Context]('/sub', mut sub)! + mut hidden := &HiddenByOther{} + + mut app := &App{} + app.register_controller[Other, Context]('/other', mut other)! + // controllers should be sorted, so this controller should be accessible + // even though it is declared last + app.register_controller[HiddenByOther, Context]('/other/hide', mut hidden)! + + vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) or { + panic('could not start vweb app') + } + }() + // app startup time + time.sleep(time.second * 10) + + spawn fn () { + time.sleep(exit_after) + assert true == false, 'timeout reached!' + exit(1) + }() +} + +fn test_app_home() { + x := http.get(localserver)! + assert x.body == 'from app' +} + +fn test_other() { + x := http.get('${localserver}/other')! + assert x.body == 'from other' +} + +fn test_sub_controller() { + x := http.get('${localserver}/other/sub')! + assert x.body == 'from sub' +} + +fn test_hidden_route() { + x := http.get('${localserver}/other/hide')! + assert x.body == 'from hidden' +} + +fn test_conflicting_controllers() { + mut other := &Other{} + + mut app := &App{} + app.register_controller[Other, Context]('/other', mut other) or { + assert true == false, 'this should not fail' + } + + app.register_controller[Other, Context]('/other', mut other) or { + assert true == false, 'this should not fail' + } + + vweb.run_at[App, Context](mut app, port: port) or { + assert err.msg() == 'conflicting paths: duplicate controller handling the route "/other"' + return + } + assert true == false, 'the previous call should have failed!' +} + +fn test_conflicting_controller_routes() { + mut other := &Other{} + + mut app := &App{} + app.register_controller[Other, Context]('/conflict', mut other) or { + assert true == false, 'this should not fail' + } + + vweb.run_at[App, Context](mut app, port: port) or { + assert err.msg() == 'conflicting paths: method "conflicting" with route "/conflict/test" should be handled by the Controller of path "/conflict"' + return + } + assert true == false, 'the previous call should have failed!' +} diff --git a/vlib/x/vweb/tests/large_payload_test.v b/vlib/x/vweb/tests/large_payload_test.v new file mode 100644 index 0000000000..17d0f65f8e --- /dev/null +++ b/vlib/x/vweb/tests/large_payload_test.v @@ -0,0 +1,122 @@ +import x.vweb +import net.http +import time +import os + +const port = 13002 + +const localserver = 'http://127.0.0.1:${port}' + +const exit_after = time.second * 10 + +const tmp_file = os.join_path(os.vtmp_dir(), 'vweb_large_payload.txt') + +pub struct App {} + +pub fn (mut app App) index(mut ctx Context) vweb.Result { + return ctx.text('Hello V!') +} + +@[post] +pub fn (mut app App) post_request(mut ctx Context) vweb.Result { + return ctx.text(ctx.req.data) +} + +pub fn (app &App) file(mut ctx Context) vweb.Result { + return ctx.file(tmp_file) +} + +pub struct Context { + vweb.Context +} + +fn testsuite_begin() { + spawn fn () { + mut app := &App{} + vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) or { + panic('could not start vweb app') + } + }() + + // app startup time + time.sleep(time.millisecond * 500) + + spawn fn () { + time.sleep(exit_after) + assert true == false, 'timeout reached!' + exit(1) + }() +} + +fn test_large_request_body() { + // string of a's of 8.96mb send over the connection + // vweb reads a maximum of 4096KB per picoev loop cycle + // this test tests if vweb is able to do multiple of these + // cycles and updates the response body each cycle + mut buf := []u8{len: vweb.max_read * 10, init: `a`} + + str := buf.bytestr() + mut x := http.post('${localserver}/post_request', str)! + + assert x.body.len == vweb.max_read * 10 +} + +fn test_large_request_header() { + // same test as test_large_request_body, but then with a large header, + // which is parsed seperately + mut buf := []u8{len: vweb.max_read * 2, init: `a`} + + str := buf.bytestr() + // make 1 header longer than vwebs max read limit + mut x := http.fetch(http.FetchConfig{ + url: localserver + header: http.new_custom_header_from_map({ + 'X-Overflow-Header': str + })! + })! + + assert x.status() == .request_entity_too_large +} + +fn test_bigger_content_length() { + data := '123456789' + mut x := http.fetch(http.FetchConfig{ + method: .post + url: '${localserver}/post_request' + header: http.new_header_from_map({ + .content_length: '10' + }) + data: data + })! + + assert x.status() == .bad_request + assert x.body == 'Mismatch of body length and Content-Length header' +} + +fn test_smaller_content_length() { + data := '123456789' + mut x := http.fetch(http.FetchConfig{ + method: .post + url: '${localserver}/post_request' + header: http.new_header_from_map({ + .content_length: '5' + }) + data: data + })! + + assert x.status() == .bad_request + assert x.body == 'Mismatch of body length and Content-Length header' +} + +fn test_sendfile() { + mut buf := []u8{len: vweb.max_write * 10, init: `a`} + os.write_file(tmp_file, buf.bytestr())! + + x := http.get('${localserver}/file')! + + assert x.body.len == vweb.max_write * 10 +} + +fn testsuite_end() { + os.rm(tmp_file)! +} diff --git a/vlib/x/vweb/tests/middleware_test.v b/vlib/x/vweb/tests/middleware_test.v new file mode 100644 index 0000000000..c1f954af5e --- /dev/null +++ b/vlib/x/vweb/tests/middleware_test.v @@ -0,0 +1,127 @@ +import x.vweb +import net.http +import os +import time + +const port = 13001 + +const localserver = 'http://127.0.0.1:${port}' + +const exit_after = time.second * 10 + +pub struct Context { + vweb.Context +pub mut: + counter int +} + +@[heap] +pub struct App { + vweb.Middleware[Context] +} + +pub fn (app &App) index(mut ctx Context) vweb.Result { + return ctx.text('from index, ${ctx.counter}') +} + +@['/bar/bar'] +pub fn (app &App) bar(mut ctx Context) vweb.Result { + return ctx.text('from bar, ${ctx.counter}') +} + +pub fn (app &App) unreachable(mut ctx Context) vweb.Result { + return ctx.text('should never be reachable!') +} + +@['/nested/route/method'] +pub fn (app &App) nested(mut ctx Context) vweb.Result { + return ctx.text('from nested, ${ctx.counter}') +} + +pub fn (app &App) after(mut ctx Context) vweb.Result { + return ctx.text('from after, ${ctx.counter}') +} + +pub fn (app &App) app_middleware(mut ctx Context) bool { + ctx.counter++ + return true +} + +fn middleware_handler(mut ctx Context) bool { + ctx.counter++ + return true +} + +fn middleware_unreachable(mut ctx Context) bool { + ctx.text('unreachable, ${ctx.counter}') + return false +} + +fn after_middleware(mut ctx Context) bool { + ctx.counter++ + ctx.res.header.add_custom('X-AFTER', ctx.counter.str()) or { panic('bad') } + return true +} + +fn testsuite_begin() { + os.chdir(os.dir(@FILE))! + + spawn fn () { + mut app := &App{} + // even though `route_use` is called first, global middleware is still executed first + app.Middleware.route_use('/unreachable', handler: middleware_unreachable) + + // global middleware + app.Middleware.use(handler: middleware_handler) + app.Middleware.use(handler: app.app_middleware) + + // should match only one slash + app.Middleware.route_use('/bar/:foo', handler: middleware_handler) + // should match multiple slashes + app.Middleware.route_use('/nested/:path...', handler: middleware_handler) + + app.Middleware.route_use('/after', handler: after_middleware, after: true) + + vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) or { + panic('could not start vweb app') + } + }() + // app startup time + time.sleep(time.second * 2) + + spawn fn () { + time.sleep(exit_after) + assert true == false, 'timeout reached!' + exit(1) + }() +} + +fn test_index() { + x := http.get(localserver)! + assert x.body == 'from index, 2' +} + +fn test_unreachable_order() { + x := http.get('${localserver}/unreachable')! + assert x.body == 'unreachable, 2' +} + +fn test_dynamic_route() { + x := http.get('${localserver}/bar/bar')! + assert x.body == 'from bar, 3' +} + +fn test_nested() { + x := http.get('${localserver}/nested/route/method')! + assert x.body == 'from nested, 3' +} + +fn test_after_middleware() { + x := http.get('${localserver}/after')! + assert x.body == 'from after, 2' + + custom_header := x.header.get_custom('X-AFTER') or { panic('should be set!') } + assert custom_header == '3' +} + +// TODO: add test for encode and decode gzip diff --git a/vlib/x/vweb/tests/static_handler_test.v b/vlib/x/vweb/tests/static_handler_test.v new file mode 100644 index 0000000000..b992106523 --- /dev/null +++ b/vlib/x/vweb/tests/static_handler_test.v @@ -0,0 +1,100 @@ +import x.vweb +import net.http +import os +import time + +const port = 13003 + +const localserver = 'http://127.0.0.1:${port}' + +const exit_after = time.second * 10 + +pub struct App { + vweb.StaticHandler +} + +pub fn (mut app App) index(mut ctx Context) vweb.Result { + return ctx.text('Hello V!') +} + +@[post] +pub fn (mut app App) post_request(mut ctx Context) vweb.Result { + return ctx.text(ctx.req.data) +} + +pub struct Context { + vweb.Context +} + +fn testsuite_begin() { + os.chdir(os.dir(@FILE))! + spawn run_app_test() + + // app startup time + time.sleep(time.second * 2) + + spawn fn () { + time.sleep(exit_after) + assert true == false, 'timeout reached!' + exit(1) + }() +} + +fn run_app_test() { + mut app := &App{} + if _ := app.handle_static('testdata', true) { + assert true == false, 'should throw unkown mime type error' + } else { + assert err.msg().starts_with('unkown MIME type for file extension ".what"'), 'throws error on unkown mime type' + } + + app.static_mime_types['.what'] = vweb.mime_types['.txt'] + app.handle_static('testdata', true) or { panic(err) } + + if _ := app.mount_static_folder_at('testdata', 'static') { + assert true == false, 'should throw invalid mount path error' + } else { + assert err.msg() == 'invalid mount path! The path should start with `/`' + } + + if _ := app.mount_static_folder_at('not_found', '/static') { + assert true == false, 'should throw mount path does not exist error' + } else { + assert err.msg() == 'directory "not_found" does not exist' + } + + app.mount_static_folder_at('testdata', '/static') or { panic(err) } + + vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) or { + panic('could not start vweb app') + } +} + +fn test_static_root() { + x := http.get('${localserver}/root.txt')! + + assert x.status() == .ok + assert x.body == 'root' +} + +fn test_scans_subdirs() { + x := http.get('${localserver}/sub_folder/sub.txt')! + + assert x.status() == .ok + assert x.body == 'sub' +} + +fn test_custom_mime_types() { + x := http.get('${localserver}/unkown_mime.what')! + + assert x.status() == .ok + assert x.header.get(.content_type)! == vweb.mime_types['.txt'] + assert x.body == 'unkown_mime' +} + +fn test_custom_folder_mount() { + x := http.get('${localserver}/static/root.txt')! + + assert x.status() == .ok + assert x.body == 'root' +} diff --git a/vlib/x/vweb/tests/testdata/root.txt b/vlib/x/vweb/tests/testdata/root.txt new file mode 100644 index 0000000000..93ca1422a8 --- /dev/null +++ b/vlib/x/vweb/tests/testdata/root.txt @@ -0,0 +1 @@ +root \ No newline at end of file diff --git a/vlib/x/vweb/tests/testdata/sub_folder/sub.txt b/vlib/x/vweb/tests/testdata/sub_folder/sub.txt new file mode 100644 index 0000000000..3de0f365ba --- /dev/null +++ b/vlib/x/vweb/tests/testdata/sub_folder/sub.txt @@ -0,0 +1 @@ +sub \ No newline at end of file diff --git a/vlib/x/vweb/tests/testdata/unkown_mime.what b/vlib/x/vweb/tests/testdata/unkown_mime.what new file mode 100644 index 0000000000..b04592d504 --- /dev/null +++ b/vlib/x/vweb/tests/testdata/unkown_mime.what @@ -0,0 +1 @@ +unkown_mime \ No newline at end of file diff --git a/vlib/x/vweb/tests/vweb_app_test.v b/vlib/x/vweb/tests/vweb_app_test.v new file mode 100644 index 0000000000..35227f4a8f --- /dev/null +++ b/vlib/x/vweb/tests/vweb_app_test.v @@ -0,0 +1,114 @@ +import x.vweb +import time +import db.sqlite + +const port = 13004 + +pub struct Context { + vweb.Context +pub mut: + user_id string +} + +pub struct App { +pub mut: + db sqlite.DB +} + +struct Article { + id int + title string + text string +} + +fn test_a_vweb_application_compiles() { + spawn fn () { + time.sleep(15 * time.second) + exit(0) + }() + mut app := &App{} + vweb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 10)! +} + +pub fn (mut ctx Context) before_request() { + ctx.user_id = ctx.get_cookie('id') or { '0' } +} + +@['/new_article'; post] +pub fn (mut app App) new_article(mut ctx Context) vweb.Result { + title := ctx.form['title'] + text := ctx.form['text'] + if title == '' || text == '' { + return ctx.text('Empty text/title') + } + article := Article{ + title: title + text: text + } + println('posting article') + println(article) + sql app.db { + insert article into Article + } or {} + + return ctx.redirect('/') +} + +pub fn (mut app App) time(mut ctx Context) vweb.Result { + return ctx.text(time.now().format()) +} + +pub fn (mut app App) time_json(mut ctx Context) vweb.Result { + return ctx.json({ + 'time': time.now().format() + }) +} + +fn (mut app App) time_json_pretty(mut ctx Context) vweb.Result { + return ctx.json_pretty({ + 'time': time.now().format() + }) +} + +struct ApiSuccessResponse[T] { + success bool + result T +} + +fn (mut app App) json_success[T](mut ctx Context, result T) { + response := ApiSuccessResponse[T]{ + success: true + result: result + } + + ctx.json(response) +} + +// should compile, this is a helper method, not exposed as a route +fn (mut app App) some_helper[T](result T) ApiSuccessResponse[T] { + response := ApiSuccessResponse[T]{ + success: true + result: result + } + return response +} + +// should compile, the route method itself is not generic +fn (mut app App) ok(mut ctx Context) vweb.Result { + return ctx.json(app.some_helper(123)) +} + +struct ExampleStruct { + example int +} + +fn (mut app App) request_raw_2(mut ctx Context) vweb.Result { + stuff := []ExampleStruct{} + app.request_raw(mut ctx, stuff) + return ctx.ok('') +} + +// should compile, this is a helper method, not exposed as a route +fn (mut app App) request_raw(mut ctx Context, foo []ExampleStruct) { + ctx.text('Hello world') +} diff --git a/vlib/x/vweb/tests/vweb_test.v b/vlib/x/vweb/tests/vweb_test.v new file mode 100644 index 0000000000..eaac77c8c4 --- /dev/null +++ b/vlib/x/vweb/tests/vweb_test.v @@ -0,0 +1,341 @@ +import os +import time +import json +import net +import net.http +import io + +const sport = 13005 +const localserver = '127.0.0.1:${sport}' +const exit_after_time = 12000 +// milliseconds +const vexe = os.getenv('VEXE') +const vweb_logfile = os.getenv('VWEB_LOGFILE') +const vroot = os.dir(vexe) +const serverexe = os.join_path(os.cache_dir(), 'vweb_test_server.exe') +const tcp_r_timeout = 10 * time.second +const tcp_w_timeout = 10 * time.second + +// setup of vweb webserver +fn testsuite_begin() { + os.chdir(vroot) or {} + if os.exists(serverexe) { + os.rm(serverexe) or {} + } +} + +fn test_a_simple_vweb_app_can_be_compiled() { + // did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/vweb/tests/vweb_test_server.v') + did_server_compile := os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/x/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>> ${os.quoted_path(vweb_logfile)} >> ${os.quoted_path(vweb_logfile)} &' + } + server_exec_cmd := '${os.quoted_path(serverexe)} ${sport} ${exit_after_time} ${suffix}' + $if debug_net_socket_client ? { + eprintln('running:\n${server_exec_cmd}') + } + $if windows { + spawn os.system(server_exec_cmd) + } $else { + res := os.system(server_exec_cmd) + assert res == 0 + } + $if macos { + time.sleep(1000 * time.millisecond) + } $else { + time.sleep(100 * time.millisecond) + } +} + +// web client tests follow +fn assert_common_headers(received string) { + assert received.starts_with('HTTP/1.1 200 OK\r\n') + 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('

ok

') +} + +// 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://${localserver}/') or { panic(err) } + assert_common_http_headers(x)! + assert x.header.get(.content_type)! == 'text/plain' + assert x.body == 'Welcome to VWeb' +} + +fn test_http_client_404() { + server := 'http://${localserver}' + url_404_list := [ + '/zxcnbnm', + '/JHKAJA', + '/unknown', + ] + for url in url_404_list { + res := http.get('${server}${url}') or { panic(err) } + assert res.status() == .not_found + assert res.body == '404 on "${url}"' + } +} + +fn test_http_client_simple() { + x := http.get('http://${localserver}/simple') or { panic(err) } + assert_common_http_headers(x)! + assert x.header.get(.content_type)! == 'text/plain' + assert x.body == 'A simple result' +} + +fn test_http_client_html_page() { + x := http.get('http://${localserver}/html_page') or { panic(err) } + assert_common_http_headers(x)! + assert x.header.get(.content_type)! == 'text/html' + assert x.body == '

ok

' +} + +fn test_http_client_settings_page() { + x := http.get('http://${localserver}/bilbo/settings') or { panic(err) } + assert_common_http_headers(x)! + assert x.body == 'username: bilbo' + // + y := http.get('http://${localserver}/kent/settings') or { panic(err) } + assert_common_http_headers(y)! + assert y.body == 'username: kent' +} + +fn test_http_client_user_repo_settings_page() { + x := http.get('http://${localserver}/bilbo/gostamp/settings') or { panic(err) } + assert_common_http_headers(x)! + assert x.body == 'username: bilbo | repository: gostamp' + // + y := http.get('http://${localserver}/kent/golang/settings') or { panic(err) } + assert_common_http_headers(y)! + assert y.body == 'username: kent | repository: golang' + // + z := http.get('http://${localserver}/missing/golang/settings') or { panic(err) } + assert z.status() == .not_found +} + +struct User { + name string + age int +} + +fn test_http_client_json_post() { + ouser := User{ + name: 'Bilbo' + age: 123 + } + json_for_ouser := json.encode(ouser) + mut x := http.post_json('http://${localserver}/json_echo', json_for_ouser) or { panic(err) } + $if debug_net_socket_client ? { + eprintln('/json_echo endpoint response: ${x}') + } + assert x.header.get(.content_type)! == 'application/json' + assert x.body == json_for_ouser + nuser := json.decode(User, x.body) or { User{} } + assert '${ouser}' == '${nuser}' + // + x = http.post_json('http://${localserver}/json', json_for_ouser) or { panic(err) } + $if debug_net_socket_client ? { + eprintln('/json endpoint response: ${x}') + } + assert x.header.get(.content_type)! == 'application/json' + assert x.body == json_for_ouser + nuser2 := json.decode(User, x.body) or { User{} } + assert '${ouser}' == '${nuser2}' +} + +fn test_http_client_multipart_form_data() { + mut form_config := http.PostMultipartFormConfig{ + form: { + 'foo': 'baz buzz' + } + } + + mut x := http.post_multipart_form('http://${localserver}/form_echo', form_config)! + + $if debug_net_socket_client ? { + eprintln('/form_echo endpoint response: ${x}') + } + assert x.body == form_config.form['foo'] + + mut files := []http.FileData{} + files << http.FileData{ + filename: 'vweb' + content_type: 'text' + data: '"vweb test"' + } + + mut form_config_files := http.PostMultipartFormConfig{ + files: { + 'file': files + } + } + + x = http.post_multipart_form('http://${localserver}/file_echo', form_config_files)! + $if debug_net_socket_client ? { + eprintln('/form_echo endpoint response: ${x}') + } + assert x.body == files[0].data +} + +fn test_login_with_multipart_form_data_send_by_fetch() { + mut form_config := http.PostMultipartFormConfig{ + form: { + 'username': 'myusername' + 'password': 'mypassword123' + } + } + x := http.post_multipart_form('http://${localserver}/login', form_config)! + assert x.status_code == 200 + assert x.status_msg == 'OK' + assert x.body == 'username: xmyusernamex | password: xmypassword123x' +} + +fn test_host() { + mut req := http.Request{ + url: 'http://${localserver}/with_host' + method: .get + } + + mut x := req.do()! + assert x.status() == .not_found + + req.add_header(.host, 'example.com') + x = req.do()! + assert x.status() == .ok +} + +fn test_http_client_shutdown_does_not_work_without_a_cookie() { + x := http.get('http://${localserver}/shutdown') or { + assert err.msg() == '' + return + } + assert x.status() == .not_found +} + +fn testsuite_end() { + // This test is guaranteed to be called last. + // It sends a request to the server to shutdown. + x := http.fetch( + url: 'http://${localserver}/shutdown' + method: .get + cookies: { + 'skey': 'superman' + } + ) or { + assert err.msg() == '' + return + } + assert x.status() == .ok + assert x.body == 'good bye' +} + +// utility code: +struct SimpleTcpClientConfig { + retries int = 4 + host string = 'static.dev' + path string = '/' + agent string = 'v/net.tcp.v' + headers string = '\r\n' + content string +} + +fn simple_tcp_client(config SimpleTcpClientConfig) !string { + mut client := &net.TcpConn(unsafe { nil }) + mut tries := 0 + for tries < config.retries { + tries++ + eprintln('> client retries: ${tries}') + client = net.dial_tcp(localserver) or { + eprintln('dial error: ${err.msg()}') + if tries > config.retries { + return err + } + time.sleep(100 * time.millisecond) + continue + } + break + } + if client == unsafe { nil } { + eprintln('could not create a tcp client connection to http://${localserver} after ${config.retries} retries') + exit(1) + } + client.set_read_timeout(tcp_r_timeout) + client.set_write_timeout(tcp_w_timeout) + defer { + client.close() or {} + } + message := '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/vlib/x/vweb/tests/vweb_test_server.v b/vlib/x/vweb/tests/vweb_test_server.v new file mode 100644 index 0000000000..650370ba36 --- /dev/null +++ b/vlib/x/vweb/tests/vweb_test_server.v @@ -0,0 +1,144 @@ +module main + +import os +import x.vweb +import time + +const known_users = ['bilbo', 'kent'] + +struct ServerContext { + vweb.Context +} + +// Custom 404 page +pub fn (mut ctx ServerContext) not_found() vweb.Result { + ctx.res.set_status(.not_found) + return ctx.html('404 on "${ctx.req.url}"') +} + +pub struct ServerApp { + port int + timeout int + global_config Config +} + +struct Config { + max_ping int +} + +fn exit_after_timeout(timeout_in_ms int) { + time.sleep(timeout_in_ms * time.millisecond) + println('>> webserver: pid: ${os.getpid()}, exiting ...') + exit(0) +} + +fn main() { + if os.args.len != 3 { + panic('Usage: `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 + spawn exit_after_timeout(timeout) + // + mut app := &ServerApp{ + port: http_port + timeout: timeout + global_config: Config{ + max_ping: 50 + } + } + eprintln('>> webserver: pid: ${os.getpid()}, started on http://localhost:${app.port}/ , with maximum runtime of ${app.timeout} milliseconds.') + vweb.run_at[ServerApp, ServerContext](mut app, host: 'localhost', port: http_port, family: .ip)! +} + +// pub fn (mut app ServerApp) init_server() { +//} + +pub fn (mut app ServerApp) index(mut ctx ServerContext) vweb.Result { + assert app.global_config.max_ping == 50 + return ctx.text('Welcome to VWeb') +} + +pub fn (mut app ServerApp) simple(mut ctx ServerContext) vweb.Result { + return ctx.text('A simple result') +} + +pub fn (mut app ServerApp) html_page(mut ctx ServerContext) vweb.Result { + return ctx.html('

ok

') +} + +// the following serve custom routes +@['/:user/settings'] +pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) vweb.Result { + if username !in known_users { + return ctx.not_found() + } + return ctx.html('username: ${username}') +} + +@['/:user/:repo/settings'] +pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username string, repository string) vweb.Result { + if username !in known_users { + return ctx.not_found() + } + return ctx.html('username: ${username} | repository: ${repository}') +} + +@['/json_echo'; post] +pub fn (mut app ServerApp) json_echo(mut ctx ServerContext) vweb.Result { + // eprintln('>>>>> received http request at /json_echo is: $app.req') + ctx.set_content_type(ctx.req.header.get(.content_type) or { '' }) + return ctx.ok(ctx.req.data) +} + +@['/login'; post] +pub fn (mut app ServerApp) login_form(mut ctx ServerContext, username string, password string) vweb.Result { + return ctx.html('username: x${username}x | password: x${password}x') +} + +@['/form_echo'; post] +pub fn (mut app ServerApp) form_echo(mut ctx ServerContext) vweb.Result { + ctx.set_content_type(ctx.req.header.get(.content_type) or { '' }) + return ctx.ok(ctx.form['foo']) +} + +@['/file_echo'; post] +pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) vweb.Result { + if 'file' !in ctx.files { + ctx.res.set_status(.internal_server_error) + return ctx.text('no file') + } + + return ctx.text(ctx.files['file'][0].data) +} + +// Make sure [post] works without the path +@[post] +pub fn (mut app ServerApp) json(mut ctx ServerContext) vweb.Result { + // eprintln('>>>>> received http request at /json is: $app.req') + ctx.set_content_type(ctx.req.header.get(.content_type) or { '' }) + return ctx.ok(ctx.req.data) +} + +@[host: 'example.com'] +@['/with_host'] +pub fn (mut app ServerApp) with_host(mut ctx ServerContext) vweb.Result { + return ctx.ok('') +} + +pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) vweb.Result { + session_key := ctx.get_cookie('skey') or { return ctx.not_found() } + if session_key != 'superman' { + return ctx.not_found() + } + spawn app.exit_gracefully() + return ctx.ok('good bye') +} + +fn (mut app ServerApp) exit_gracefully() { + eprintln('>> webserver: exit_gracefully') + time.sleep(100 * time.millisecond) + exit(0) +} diff --git a/vlib/x/vweb/vweb.v b/vlib/x/vweb/vweb.v new file mode 100644 index 0000000000..04428c700b --- /dev/null +++ b/vlib/x/vweb/vweb.v @@ -0,0 +1,902 @@ +module vweb + +import io +import net +import net.http +import net.urllib +import os +import time +import strings +import picoev + +// max read and write limits in bytes +const max_read = 8096 + +const max_write = 8096 * 2 + +// A type which doesn't get filtered inside templates +pub type RawHtml = string + +// A dummy structure that returns from routes to indicate that you actually sent something to a user +@[noinit] +pub struct Result {} + +pub const methods_with_form = [http.Method.post, .put, .patch] + +pub const headers_close = http.new_custom_header_from_map({ + 'Server': 'VWeb' + http.CommonHeader.connection.str(): 'close' +}) or { panic('should never fail') } + +pub const http_302 = http.new_response( + status: .found + body: '302 Found' + header: headers_close +) + +pub const http_400 = http.new_response( + status: .bad_request + body: '400 Bad Request' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) +) + +pub const http_404 = http.new_response( + status: .not_found + body: '404 Not Found' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) +) + +pub const http_413 = http.new_response( + status: .request_entity_too_large + body: '413 Request entity is too large' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) +) + +pub const http_500 = http.new_response( + status: .internal_server_error + body: '500 Internal Server Error' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) +) + +pub const mime_types = { + '.aac': 'audio/aac' + '.abw': 'application/x-abiword' + '.arc': 'application/x-freearc' + '.avi': 'video/x-msvideo' + '.azw': 'application/vnd.amazon.ebook' + '.bin': 'application/octet-stream' + '.bmp': 'image/bmp' + '.bz': 'application/x-bzip' + '.bz2': 'application/x-bzip2' + '.cda': 'application/x-cdf' + '.csh': 'application/x-csh' + '.css': 'text/css' + '.csv': 'text/csv' + '.doc': 'application/msword' + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + '.eot': 'application/vnd.ms-fontobject' + '.epub': 'application/epub+zip' + '.gz': 'application/gzip' + '.gif': 'image/gif' + '.htm': 'text/html' + '.html': 'text/html' + '.ico': 'image/vnd.microsoft.icon' + '.ics': 'text/calendar' + '.jar': 'application/java-archive' + '.jpeg': 'image/jpeg' + '.jpg': 'image/jpeg' + '.js': 'text/javascript' + '.json': 'application/json' + '.jsonld': 'application/ld+json' + '.mid': 'audio/midi audio/x-midi' + '.midi': 'audio/midi audio/x-midi' + '.mjs': 'text/javascript' + '.mp3': 'audio/mpeg' + '.mp4': 'video/mp4' + '.mpeg': 'video/mpeg' + '.mpkg': 'application/vnd.apple.installer+xml' + '.odp': 'application/vnd.oasis.opendocument.presentation' + '.ods': 'application/vnd.oasis.opendocument.spreadsheet' + '.odt': 'application/vnd.oasis.opendocument.text' + '.oga': 'audio/ogg' + '.ogv': 'video/ogg' + '.ogx': 'application/ogg' + '.opus': 'audio/opus' + '.otf': 'font/otf' + '.png': 'image/png' + '.pdf': 'application/pdf' + '.php': 'application/x-httpd-php' + '.ppt': 'application/vnd.ms-powerpoint' + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + '.rar': 'application/vnd.rar' + '.rtf': 'application/rtf' + '.sh': 'application/x-sh' + '.svg': 'image/svg+xml' + '.swf': 'application/x-shockwave-flash' + '.tar': 'application/x-tar' + '.tif': 'image/tiff' + '.tiff': 'image/tiff' + '.ts': 'video/mp2t' + '.ttf': 'font/ttf' + '.txt': 'text/plain' + '.vsd': 'application/vnd.visio' + '.wasm': 'application/wasm' + '.wav': 'audio/wav' + '.weba': 'audio/webm' + '.webm': 'video/webm' + '.webp': 'image/webp' + '.woff': 'font/woff' + '.woff2': 'font/woff2' + '.xhtml': 'application/xhtml+xml' + '.xls': 'application/vnd.ms-excel' + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + '.xml': 'application/xml' + '.xul': 'application/vnd.mozilla.xul+xml' + '.zip': 'application/zip' + '.3gp': 'video/3gpp' + '.3g2': 'video/3gpp2' + '.7z': 'application/x-7z-compressed' +} + +pub const max_http_post_size = 1024 * 1024 + +pub const default_port = 8080 + +struct Route { + methods []http.Method + path string + host string +mut: + middlewares []voidptr + after_middlewares []voidptr +} + +// Generate route structs for an app +fn generate_routes[A, X](app &A) !map[string]Route { + // Parsing methods attributes + mut routes := map[string]Route{} + $for method in A.methods { + $if method.return_type is Result { + http_methods, route_path, host := parse_attrs(method.name, method.attrs) or { + return error('error parsing method attributes: ${err}') + } + + routes[method.name] = Route{ + methods: http_methods + path: route_path + host: host + } + + $if A is MiddlewareApp { + routes[method.name].middlewares = app.Middleware.get_handlers_for_route[X](route_path) + routes[method.name].after_middlewares = app.Middleware.get_handlers_for_route_after[X](route_path) + } + } + } + return routes +} + +// run - start a new VWeb server, listening to all available addresses, at the specified `port` +pub fn run[A, X](mut global_app A, port int) { + run_at[A, X](mut global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) } +} + +@[params] +pub struct RunParams { + // use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1 + family net.AddrFamily = .ip6 + host string + port int = 8080 + show_startup_message bool = true + timeout_in_seconds int = 30 +} + +struct FileResponse { +pub mut: + open bool + file os.File + total i64 + pos i64 +} + +// close the open file and reset the struct to its default values +pub fn (mut fr FileResponse) done() { + fr.open = false + fr.file.close() + fr.total = 0 + fr.pos = 0 +} + +struct StringResponse { +pub mut: + open bool + str string + pos i64 +} + +// free the current string and reset the struct to its default values +@[manualfree] +pub fn (mut sr StringResponse) done() { + sr.open = false + sr.pos = 0 + unsafe { sr.str.free() } +} + +// EV context +struct RequestParams { + global_app voidptr + controllers []&ControllerPath + routes &map[string]Route + timeout_in_seconds int +mut: + // request body buffer + buf &u8 = unsafe { nil } + // idx keeps track of how much of the request body has been read + // for each incomplete request, see `handle_conn` + idx []int + incomplete_requests []http.Request + file_responses []FileResponse + string_responses []StringResponse +} + +// run_at - start a new VWeb server, listening only on a specific address `host`, at the specified `port` +// Example: vweb.run_at(new_app(), vweb.RunParams{ host: 'localhost' port: 8099 family: .ip }) or { panic(err) } +@[direct_array_access; manualfree] +pub fn run_at[A, X](mut global_app A, params RunParams) ! { + if params.port <= 0 || params.port > 65535 { + return error('invalid port number `${params.port}`, it should be between 1 and 65535') + } + + routes := generate_routes[A, X](global_app)! + controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)! + + if params.show_startup_message { + println('[Vweb] Running app on http://localhost:${params.port}/') + } + flush_stdout() + + mut pico_context := &RequestParams{ + global_app: unsafe { global_app } + controllers: controllers_sorted + routes: &routes + timeout_in_seconds: params.timeout_in_seconds + } + + pico_context.idx = []int{len: picoev.max_fds} + // reserve space for read and write buffers + pico_context.buf = unsafe { malloc_noscan(picoev.max_fds * vweb.max_read + 1) } + defer { + unsafe { free(pico_context.buf) } + } + pico_context.incomplete_requests = []http.Request{len: picoev.max_fds} + pico_context.file_responses = []FileResponse{len: picoev.max_fds} + pico_context.string_responses = []StringResponse{len: picoev.max_fds} + + mut pico := picoev.new( + port: params.port + raw_cb: ev_callback[A, X] + user_data: pico_context + timeout_secs: params.timeout_in_seconds + family: params.family + ) + + // Forever accept every connection that comes + pico.serve() +} + +@[direct_array_access] +fn ev_callback[A, X](mut pv picoev.Picoev, fd int, events int) { + mut params := unsafe { &RequestParams(pv.user_data) } + + if events == picoev.picoev_write { + $if trace_picoev_callback ? { + eprintln('> write event on file descriptor ${fd}') + } + + if params.file_responses[fd].open { + handle_write_file(mut pv, mut params, fd) + } else { + handle_write_string(mut pv, mut params, fd) + } + } else { + $if trace_picoev_callback ? { + eprintln('> read event on file descriptor ${fd}') + } + handle_read[A, X](mut pv, mut params, fd) + } +} + +// handle_write_file reads data from a file and sends that data over the socket. +@[direct_array_access; manualfree] +fn handle_write_file(mut pv picoev.Picoev, mut params RequestParams, fd int) { + mut bytes_to_write := int(params.file_responses[fd].total - params.file_responses[fd].pos) + + if bytes_to_write > vweb.max_write { + bytes_to_write = vweb.max_write + } + data := unsafe { malloc(bytes_to_write) } + defer { + unsafe { free(data) } + } + + mut conn := &net.TcpConn{ + sock: net.tcp_socket_from_handle_raw(fd) + handle: fd + is_blocking: false + } + + // TODO: use `sendfile` in linux for optimizations (?) + params.file_responses[fd].file.read_into_ptr(data, bytes_to_write) or { + params.file_responses[fd].done() + pv.close_conn(fd) + return + } + actual_written := send_string_ptr(mut conn, data, bytes_to_write) or { + params.file_responses[fd].done() + pv.close_conn(fd) + return + } + params.file_responses[fd].pos += actual_written + if params.file_responses[fd].pos == params.file_responses[fd].total { + // file is done writing + params.file_responses[fd].done() + pv.close_conn(fd) + return + } +} + +// handle_write_string reads data from a string and sends that data over the socket +@[direct_array_access] +fn handle_write_string(mut pv picoev.Picoev, mut params RequestParams, fd int) { + mut bytes_to_write := int(params.string_responses[fd].str.len - params.string_responses[fd].pos) + + if bytes_to_write > vweb.max_write { + bytes_to_write = vweb.max_write + } + + mut conn := &net.TcpConn{ + sock: net.tcp_socket_from_handle_raw(fd) + handle: fd + is_blocking: false + } + + // pointer magic to start at the correct position in the buffer + data := unsafe { params.string_responses[fd].str.str + params.string_responses[fd].pos } + actual_written := send_string_ptr(mut conn, data, bytes_to_write) or { + params.string_responses[fd].done() + pv.close_conn(fd) + return + } + params.string_responses[fd].pos += actual_written + if params.string_responses[fd].pos == params.string_responses[fd].str.len { + // done writing + params.string_responses[fd].done() + pv.close_conn(fd) + return + } +} + +// handle_read reads data from the connection and if the request is complete +// it calls `handle_route` and closes the connection. +// If the request is not complete it stores the incomplete request in `params` +// and the conenction stays open until it is ready to read again +@[direct_array_access; manualfree] +fn handle_read[A, X](mut pv picoev.Picoev, mut params RequestParams, fd int) { + mut conn := &net.TcpConn{ + sock: net.tcp_socket_from_handle_raw(fd) + handle: fd + is_blocking: false + } + + // cap the max_read to 8KB + mut reader := io.new_buffered_reader(reader: conn, cap: vweb.max_read) + defer { + unsafe { + reader.free() + } + } + + // take the previous incomplete request + mut req := params.incomplete_requests[fd] + + // check if there is an incomplete request for this file desriptor + if params.idx[fd] == 0 { + // set the read and write timeout according to picoev settings when the + // connection is first encountered + conn.set_read_timeout(params.timeout_in_seconds) + conn.set_write_timeout(params.timeout_in_seconds) + // first time that this connection is being read from, so we parse the + // request header first + req = http.parse_request_head(mut reader) or { + // Prevents errors from being thrown when BufferedReader is empty + if err.msg() != 'none' { + eprintln('[vweb] error parsing request: ${err}') + } + pv.close_conn(fd) + params.incomplete_requests[fd] = http.Request{} + return + } + if reader.total_read >= vweb.max_read { + // throw an error when the request header is larger than 8KB + // same limit that apache handles + eprintln('[vweb] error parsing request: too large') + fast_send_resp(mut conn, vweb.http_413) or {} + + pv.close_conn(fd) + params.incomplete_requests[fd] = http.Request{} + return + } + } + + // check if the request has a body + content_length := req.header.get(.content_length) or { '0' } + if content_length.int() > 0 { + mut max_bytes_to_read := vweb.max_read - reader.total_read + mut bytes_to_read := content_length.int() - params.idx[fd] + // cap the bytes to read to 8KB for the body, including the request headers if any + if bytes_to_read > vweb.max_read - reader.total_read { + bytes_to_read = vweb.max_read - reader.total_read + } + + mut buf_ptr := params.buf + unsafe { + buf_ptr += fd * vweb.max_read // pointer magic + } + // convert to []u8 for BufferedReader + mut buf := unsafe { buf_ptr.vbytes(max_bytes_to_read) } + + n := reader.read(mut buf) or { + eprintln('[vweb] error parsing request: ${err}') + pv.close_conn(fd) + params.incomplete_requests[fd] = http.Request{} + return + } + + // there is no more data to be sent, but it is less than the Content-Length header + // so it is a mismatch of body length and content length. + // Or if there is more data received then the Content-Length header specified + if (n == 0 && params.idx[fd] != 0) || params.idx[fd] + n > content_length.int() { + fast_send_resp(mut conn, http.new_response( + status: .bad_request + body: 'Mismatch of body length and Content-Length header' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(vweb.headers_close) + )) or {} + pv.close_conn(fd) + params.incomplete_requests[fd] = http.Request{} + return + } else if n < bytes_to_read || params.idx[fd] + n < content_length.int() { + // request is incomplete wait until the socket becomes ready to read again + params.idx[fd] += n + // TODO: change this to a memcpy function? + req.data += buf.bytestr() + params.incomplete_requests[fd] = req + return + } else { + // request is complete: n = bytes_to_read + req.data += buf[0..n].bytestr() + } + } + + defer { + // reset content-length index, the http request and close the connection + params.incomplete_requests[fd] = http.Request{} + params.idx[fd] = 0 + } + + if completed_context := handle_request[A, X](mut conn, req, params) { + if completed_context.takeover { + // the connection should be kept open, but removed from the picoev loop. + // This way vweb can continue handling other connections and the user can + // keep the connection open indefinitely + pv.del(fd) + return + } + + // TODO: At this point the Context can safely be freed when this function returns. + // The user will have to clone the context if the context object should be kept. + // defer { + // completed_context.free() + // } + + match completed_context.return_type { + .normal { + // small optimization: if the response is small write it immediately + // the socket is most likely able to write all the data without blocking. + // See Context.send_file for why we use max_read instead of max_write. + if completed_context.res.body.len < vweb.max_read { + fast_send_resp(mut conn, completed_context.res) or {} + pv.close_conn(fd) + } else { + params.string_responses[fd].str = completed_context.res.body + res := pv.add(fd, picoev.picoev_write, params.timeout_in_seconds, + picoev.raw_callback) + // picoev error + if res == -1 { + // should not happen + params.string_responses[fd].done() + fast_send_resp(mut conn, vweb.http_500) or {} + pv.close_conn(fd) + return + } + // no errors we can send the HTTP headers + fast_send_resp_header(mut conn, completed_context.res) or {} + } + } + .file { + // save file information + length := completed_context.res.header.get(.content_length) or { + fast_send_resp(mut conn, vweb.http_500) or {} + return + } + params.file_responses[fd].total = length.i64() + params.file_responses[fd].file = os.open(completed_context.return_file) or { + // Context checks if the file is valid, so this should never happen + fast_send_resp(mut conn, vweb.http_500) or {} + params.file_responses[fd].done() + pv.close_conn(fd) + return + } + params.file_responses[fd].open = true + + res := pv.add(fd, picoev.picoev_write, params.timeout_in_seconds, picoev.raw_callback) + // picoev error + if res == -1 { + // should not happen + fast_send_resp(mut conn, vweb.http_500) or {} + params.file_responses[fd].done() + pv.close_conn(fd) + return + } + // no errors we can send the HTTP headers + fast_send_resp_header(mut conn, completed_context.res) or {} + } + } + } else { + pv.close_conn(fd) + } +} + +fn handle_request[A, X](mut conn net.TcpConn, req http.Request, params &RequestParams) ?Context { + mut global_app := unsafe { &A(params.global_app) } + + // TODO: change this variable to include the total wait time over each network cycle + // maybe store it in Request.user_ptr ? + page_gen_start := time.ticks() + + $if trace_request ? { + dump(req) + } + $if trace_request_url ? { + dump(req.url) + } + + // parse the URL, query and form data + mut url := urllib.parse(req.url) or { + eprintln('[vweb] error parsing path: ${err}') + return none + } + query := parse_query_from_url(url) + form, files := parse_form_from_request(req) or { + // Bad request + eprintln('[vweb] error parsing form: ${err.msg()}') + conn.write(vweb.http_400.bytes()) or {} + return none + } + + // remove the port from the HTTP Host header + host_with_port := req.header.get(.host) or { '' } + host, _ := urllib.split_host_port(host_with_port) + + // Create Context with request data + mut ctx := Context{ + req: req + page_gen_start: page_gen_start + conn: conn + query: query + form: form + files: files + } + + $if A is StaticApp { + ctx.custom_mime_types = global_app.static_mime_types.clone() + } + + // match controller paths + $if A is ControllerInterface { + if completed_context := handle_controllers[X](params.controllers, ctx, mut url, + host) + { + return completed_context + } + } + + // create a new user context and pass the vweb's context + mut user_context := X{} + user_context.Context = ctx + + handle_route[A, X](mut global_app, mut user_context, url, host, params.routes) + + return user_context.Context +} + +fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string, routes &map[string]Route) { + mut route := Route{} + mut middleware_has_sent_response := false + + defer { + // execute middleware functions after vweb is done and before the response is send + mut was_done := true + $if A is MiddlewareApp { + if middleware_has_sent_response == false { + // if the middleware doesn't send an alternate response, but only changes the + // response object we only have to check if the `done` was previously set to true + was_done = user_context.Context.done + // reset `done` so the middleware functions can return a different response + // 1 time only, since the `done` guard is still present in + // `Context.send_response_to_client` + user_context.Context.done = false + + // no need to check the result of `validate_middleware`, since a response has to be sent + // anyhow. This function makes sure no further middleware is executed. + validate_middleware[X](mut user_context, app.Middleware.get_global_handlers_after[X]()) + validate_middleware[X](mut user_context, route.after_middlewares) + } + } + // send only the headers, because if the response body is too big, TcpConn code will + // actually block, because it has to wait for the socket to become ready to write. Vweb + // will handle this case. + if !was_done && !user_context.Context.done && !user_context.Context.takeover { + eprintln('[vweb] handler for route "${url.path}" does not send any data!') + // send response anyway so the connection won't block + // fast_send_resp_header(mut user_context.conn, user_context.res) or {} + } else if !user_context.Context.takeover { + // fast_send_resp_header(mut user_context.conn, user_context.res) or {} + } + // Context.takeover is set to true, so the user must close the connection and sent a response. + } + + url_words := url.path.split('/').filter(it != '') + + $if vweb_livereload ? { + if url.path.starts_with('/vweb_livereload/') { + if url.path.ends_with('current') { + user_context.handle_vweb_livereload_current() + return + } + if url.path.ends_with('script.js') { + user_context.handle_vweb_livereload_script() + return + } + } + } + + // first execute before_request + user_context.before_request() + if user_context.Context.done { + return + } + + // then execute global middleware functions + $if A is MiddlewareApp { + if validate_middleware[X](mut user_context, app.Middleware.get_global_handlers[X]()) == false { + middleware_has_sent_response = true + return + } + } + + $if A is StaticApp { + if serve_if_static[A, X](app, mut user_context, url, host) { + // successfully served a static file + return + } + } + + // Route matching and match route specific middleware as last step + $for method in A.methods { + $if method.return_type is Result { + route = (*routes)[method.name] or { + eprintln('[vweb] parsed attributes for the `${method.name}` are not found, skipping...') + Route{} + } + + // Skip if the HTTP request method does not match the attributes + if user_context.Context.req.method in route.methods { + // Used for route matching + route_words := route.path.split('/').filter(it != '') + + // Skip if the host does not match or is empty + if route.host == '' || route.host == host { + // Route immediate matches first + // For example URL `/register` matches route `/:user`, but `fn register()` + // should be called first. + if !route.path.contains('/:') && url_words == route_words { + // We found a match + $if A is MiddlewareApp { + if validate_middleware[X](mut user_context, route.middlewares) == false { + middleware_has_sent_response = true + return + } + } + + if user_context.Context.req.method == .post && method.args.len > 1 { + // Populate method args with form values + mut args := []string{cap: method.args.len + 1} + for param in method.args[1..] { + args << user_context.Context.form[param.name] + } + app.$method(mut user_context, args) + } else { + app.$method(mut user_context) + } + return + } + + if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { + $if A is MiddlewareApp { + if validate_middleware[X](mut user_context, route.middlewares) == false { + middleware_has_sent_response = true + return + } + } + + app.$method(mut user_context) + return + } + + if params := route_matches(url_words, route_words) { + $if A is MiddlewareApp { + if validate_middleware[X](mut user_context, route.middlewares) == false { + middleware_has_sent_response = true + return + } + } + + method_args := params.clone() + if method_args.len + 1 != method.args.len { + eprintln('[vweb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the vweb route `${method.attrs}` (${method_args.len})') + } + app.$method(mut user_context, method_args) + return + } + } + } + } + } + // return 404 + user_context.not_found() + route = Route{} + return +} + +fn route_matches(url_words []string, route_words []string) ?[]string { + // URL path should be at least as long as the route path + // except for the catchall route (`/:path...`) + if route_words.len == 1 && route_words[0].starts_with(':') && route_words[0].ends_with('...') { + return ['/' + url_words.join('/')] + } + if url_words.len < route_words.len { + return none + } + + mut params := []string{cap: url_words.len} + if url_words.len == route_words.len { + for i in 0 .. url_words.len { + if route_words[i].starts_with(':') { + // We found a path 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 +} + +// 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[A, X](app &A, mut user_context X, url urllib.URL, host string) bool { + // TODO: handle url parameters properly - for now, ignore them + static_file := app.static_files[url.path] or { return false } + + // StaticHandler ensures that the mime type exists on either the App or in vweb + ext := os.file_ext(static_file) + mut mime_type := app.static_mime_types[ext] or { vweb.mime_types[ext] } + + static_host := app.static_hosts[url.path] or { '' } + if static_file == '' || mime_type == '' { + return false + } + if static_host != '' && static_host != host { + return false + } + + user_context.send_file(mime_type, static_file) + return true +} + +// send a string over `conn` +fn send_string(mut conn net.TcpConn, s string) ! { + $if trace_send_string_conn ? { + eprintln('> send_string: conn: ${ptr_str(conn)}') + } + $if trace_response ? { + eprintln('> send_string:\n${s}\n') + } + if voidptr(conn) == unsafe { nil } { + return error('connection was closed before send_string') + } + conn.write_string(s)! +} + +// send a string ptr over `conn` +fn send_string_ptr(mut conn net.TcpConn, ptr &u8, len int) !int { + $if trace_send_string_conn ? { + eprintln('> send_string: conn: ${ptr_str(conn)}') + } + // $if trace_response ? { + // eprintln('> send_string:\n${s}\n') + // } + if voidptr(conn) == unsafe { nil } { + return error('connection was closed before send_string') + } + return conn.write_ptr(ptr, len) +} + +fn fast_send_resp_header(mut conn net.TcpConn, resp http.Response) ! { + mut sb := strings.new_builder(resp.body.len + 200) + sb.write_string('HTTP/') + sb.write_string(resp.http_version) + sb.write_string(' ') + sb.write_decimal(resp.status_code) + sb.write_string(' ') + sb.write_string(resp.status_msg) + sb.write_string('\r\n') + + resp.header.render_into_sb(mut sb, + version: resp.version() + ) + sb.write_string('\r\n') + send_string(mut conn, sb.str())! +} + +// Formats resp to a string suitable for HTTP response transmission +// A fast version of `resp.bytestr()` used with +// `send_string(mut ctx.conn, resp.bytestr())` +fn fast_send_resp(mut conn net.TcpConn, resp http.Response) ! { + fast_send_resp_header(mut conn, resp)! + send_string(mut conn, resp.body)! +} diff --git a/vlib/x/vweb/vweb_livereload.v b/vlib/x/vweb/vweb_livereload.v new file mode 100644 index 0000000000..5e0fcdca2d --- /dev/null +++ b/vlib/x/vweb/vweb_livereload.v @@ -0,0 +1,48 @@ +module vweb + +import time + +// Note: to use live reloading while developing, the suggested workflow is doing: +// `v -d vweb_livereload watch --keep run your_vweb_server_project.v` +// in one shell, then open the start page of your vweb app in a browser. +// +// While developing, just open your files and edit them, then just save your +// changes. Once you save, the watch command from above, will restart your server, +// and your HTML pages will detect that shortly, then they will refresh themselves +// automatically. + +// vweb_livereload_server_start records, when the vweb server process started. +// That is later used by the /script.js and /current endpoints, which are active, +// if you have compiled your vweb project with `-d vweb_livereload`, to detect +// whether the web server has been restarted. +const vweb_livereload_server_start = time.ticks().str() + +// handle_vweb_livereload_current serves a small text file, containing the +// timestamp/ticks corresponding to when the vweb server process was started +@[if vweb_livereload ?] +fn (mut ctx Context) handle_vweb_livereload_current() { + ctx.send_response_to_client('text/plain', vweb.vweb_livereload_server_start) +} + +// handle_vweb_livereload_script serves a small dynamically generated .js file, +// that contains code for polling the vweb server, and reloading the page, if it +// detects that the vweb server is newer than the vweb server, that served the +// .js file originally. +@[if vweb_livereload ?] +fn (mut ctx Context) handle_vweb_livereload_script() { + res := '"use strict"; +function vweb_livereload_checker_fn(started_at) { + fetch("/vweb_livereload/" + started_at + "/current", { cache: "no-cache" }) + .then(response=>response.text()) + .then(function(current_at) { + // console.log(started_at); console.log(current_at); + if(started_at !== current_at){ + // the app was restarted on the server: + window.location.reload(); + } + }); +} +const vweb_livereload_checker = setInterval(vweb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${vweb.vweb_livereload_server_start}"); +' + ctx.send_response_to_client('text/javascript', res) +}