diff --git a/vlib/vweb/README.md b/vlib/vweb/README.md index e7ad29eb46..7aa2fc3af1 100644 --- a/vlib/vweb/README.md +++ b/vlib/vweb/README.md @@ -378,6 +378,109 @@ pub fn (mut app App) with_auth() bool { } ``` +### Controllers +Controllers can be used to split up app logic so you are able to have one struct +per `"/"`. E.g. a struct `Admin` for urls starting with `"/admin"` and a struct `Foo` +for urls starting with `"/foo"` + +**Example:** +```v +module main + +import vweb + +struct App { + vweb.Context + vweb.Controller +} + +struct Admin { + vweb.Context +} + +struct Foo { + vweb.Context +} + +fn main() { + mut app := &App{ + controllers: [ + vweb.controller('/admin', &Admin{}), + vweb.controller('/foo', &Foo{}), + ] + } + vweb.run(app, 8080) +} +``` + +You can do everything with a controller struct as with a regular `App` struct. +The only difference being is that only the main app that is being passed to `vweb.run` +is able to have controllers. If you add `vweb.Controller` on a controller struct it +will simply be ignored. + +#### Routing +Any route inside a controller struct is treated as a relative route to its controller namespace. + +```v ignore +['/path'] +pub fn (mut app Admin) path vweb.Result { + return app.text('Admin') +} +``` +When we created the controller with `vweb.controller('/admin', &Admin{})` 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 fallback routes or duplicate routes, so if we add the following +route to the example the code will produce an error. + +```v ignore +['/admin/path'] +pub fn (mut app App) admin_path vweb.Result { + return app.text('Admin overwrite') +} +``` +There will be an error, because the controller `Admin` handles all routes starting with +`"/admin"`; the method `admin_path` is unreachable. + +#### Databases and `[vweb_global]` in controllers + +Fields with `[vweb_global]` like a database have to passed to each controller individually. + +**Example:** +```v +module main + +import vweb +import db.sqlite + +struct App { + vweb.Context + vweb.Controller +pub mut: + db sqlite.DB [vweb_global] +} + +struct Admin { + vweb.Context +pub mut: + db sqlite.DB [vweb_global] +} + +fn main() { + mut db := sqlite.connect('db')! + + mut app := &App{ + db: db + controllers: [ + vweb.controller('/admin', &Admin{ + db: db + }), + ] + } +} +``` + ### Responses #### - set_status diff --git a/vlib/vweb/tests/controller_duplicate_server.v b/vlib/vweb/tests/controller_duplicate_server.v new file mode 100644 index 0000000000..3bb22e3bd6 --- /dev/null +++ b/vlib/vweb/tests/controller_duplicate_server.v @@ -0,0 +1,41 @@ +module main + +import vweb +import time +import os + +struct App { + vweb.Context + vweb.Controller +} + +struct Admin { + vweb.Context +} + +['/admin/duplicate'] +fn (mut app App) duplicate() vweb.Result { + return app.text('duplicate') +} + +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: `controller_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`') + } + http_port := os.args[1].int() + assert http_port > 0 + timeout := os.args[2].int() + mut app_dup := &App{ + controllers: [ + vweb.controller('/admin', &Admin{}), + ] + } + + vweb.run_at(app_dup, host: 'localhost', port: http_port, family: .ip) or { panic(err) } +} diff --git a/vlib/vweb/tests/controller_test.v b/vlib/vweb/tests/controller_test.v new file mode 100644 index 0000000000..a4e4a72574 --- /dev/null +++ b/vlib/vweb/tests/controller_test.v @@ -0,0 +1,133 @@ +import os +import time +import json +import net +import net.http +import io + +const ( + sport = 12382 + sport2 = 12383 + localserver = '127.0.0.1:${sport}' + exit_after_time = 12000 // milliseconds + vexe = os.getenv('VEXE') + vweb_logfile = os.getenv('VWEB_LOGFILE') + vroot = os.dir(vexe) + serverexe = os.join_path(os.cache_dir(), 'controller_test_server.exe') + tcp_r_timeout = 30 * time.second + tcp_w_timeout = 30 * time.second +) + +// setup of vweb webserver +fn testsuite_begin() { + os.chdir(vroot) or {} + if os.exists(serverexe) { + os.rm(serverexe) or {} + } +} + +fn test_middleware_vweb_app_can_be_compiled() { + // did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/vweb/tests/controller_test_server.vv') + // TODO: find out why it does not compile with -usecache and -g + did_server_compile := os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/vweb/tests/controller_test_server.v') + assert did_server_compile == 0 + assert os.exists(serverexe) +} + +fn test_middleware_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) + } +} + +// test functions: + +fn test_app_home() { + x := http.get('http://${localserver}/') or { panic(err) } + assert x.body == 'App' +} + +fn test_app_path() { + x := http.get('http://${localserver}/path') or { panic(err) } + assert x.body == 'App path' +} + +fn test_admin_home() { + x := http.get('http://${localserver}/admin/') or { panic(err) } + assert x.body == 'Admin' +} + +fn test_admin_path() { + x := http.get('http://${localserver}/admin/path') or { panic(err) } + assert x.body == 'Admin path' +} + +fn test_other_home() { + x := http.get('http://${localserver}/other/') or { panic(err) } + assert x.body == 'Other' +} + +fn test_other_path() { + x := http.get('http://${localserver}/other/path') or { panic(err) } + assert x.body == 'Other path' +} + +fn test_shutdown() { + // 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' +} + +fn test_duplicate_route() { + did_server_compile := os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/vweb/tests/controller_duplicate_server.v') + assert did_server_compile == 0 + assert os.exists(serverexe) + + mut suffix := '' + + if vweb_logfile != '' { + suffix = ' 2>> ${os.quoted_path(vweb_logfile)} >> ${os.quoted_path(vweb_logfile)} &' + } + server_exec_cmd := '${os.quoted_path(serverexe)} ${sport2} ${exit_after_time} ${suffix}' + $if debug_net_socket_client ? { + eprintln('running:\n${server_exec_cmd}') + } + $if windows { + task := spawn os.execute(server_exec_cmd) + res := task.wait() + assert res.output.contains('V panic: method "duplicate" with route "/admin/duplicate" should be handled by the Controller of "/admin"') + } $else { + res := os.execute(server_exec_cmd) + assert res.output.contains('V panic: method "duplicate" with route "/admin/duplicate" should be handled by the Controller of "/admin"') + } +} diff --git a/vlib/vweb/tests/controller_test_server.v b/vlib/vweb/tests/controller_test_server.v new file mode 100644 index 0000000000..9cb0d8aa52 --- /dev/null +++ b/vlib/vweb/tests/controller_test_server.v @@ -0,0 +1,93 @@ +module main + +import vweb +import time +import os + +struct App { + vweb.Context + vweb.Controller + timeout int +} + +struct Admin { + vweb.Context +} + +struct Other { + vweb.Context +} + +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: `controller_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`') + } + http_port := os.args[1].int() + assert http_port > 0 + timeout := os.args[2].int() + spawn exit_after_timeout(timeout) + + mut app := &App{ + timeout: timeout + controllers: [ + vweb.controller('/admin', &Admin{}), + vweb.controller('/other', &Other{}), + ] + } + + eprintln('>> webserver: pid: ${os.getpid()}, started on http://localhost:${http_port}/ , with maximum runtime of ${app.timeout} milliseconds.') + vweb.run_at(app, host: 'localhost', port: http_port, family: .ip)! +} + +['/'] +fn (mut app App) home() vweb.Result { + return app.text('App') +} + +['/path'] +fn (mut app App) app_path() vweb.Result { + return app.text('App path') +} + +['/'] +fn (mut app Admin) admin_home() vweb.Result { + return app.text('Admin') +} + +['/path'] +fn (mut app Admin) admin_path() vweb.Result { + return app.text('Admin path') +} + +['/'] +fn (mut app Other) other_home() vweb.Result { + return app.text('Other') +} + +['/path'] +fn (mut app Other) other_path() vweb.Result { + return app.text('Other path') +} + +// utility functions: + +pub fn (mut app App) shutdown() vweb.Result { + session_key := app.get_cookie('skey') or { return app.not_found() } + if session_key != 'superman' { + return app.not_found() + } + spawn app.gracefull_exit() + return app.ok('good bye') +} + +fn (mut app App) gracefull_exit() { + eprintln('>> webserver: gracefull_exit') + time.sleep(100 * time.millisecond) + exit(0) +} diff --git a/vlib/vweb/vweb.v b/vlib/vweb/vweb.v index 0176a1553f..fe6fff220e 100644 --- a/vlib/vweb/vweb.v +++ b/vlib/vweb/vweb.v @@ -381,6 +381,58 @@ interface MiddlewareInterface { middlewares map[string][]Middleware } +// Generate route structs for an app +fn generate_routes[T](app &T) !map[string]Route { + // Parsing methods attributes + mut routes := map[string]Route{} + $for method in T.methods { + http_methods, route_path, middleware := parse_attrs(method.name, method.attrs) or { + return error('error parsing method attributes: ${err}') + } + + routes[method.name] = Route{ + methods: http_methods + path: route_path + middleware: middleware + } + } + return routes +} + +type ControllerHandler = fn (ctx Context, mut url urllib.URL, tid int) + +pub struct ControllerPath { + path string + handler ControllerHandler +} + +interface ControllerInterface { + controllers []&ControllerPath +} + +pub struct Controller { +mut: + controllers []&ControllerPath +} + +// controller generates a new Controller for the main app +pub fn controller[T](path string, global_app &T) &ControllerPath { + routes := generate_routes(global_app) or { panic(err.msg()) } + + // generate struct with closure so the generic type is encapsulated in the closure + // no need to type `ControllerHandler` as generic since it's not needed for closures + return &ControllerPath{ + path: path + handler: fn [global_app, path, routes] [T](ctx Context, mut url urllib.URL, tid int) { + // request_app is freed in `handle_route` + mut request_app := new_request_app[T](global_app, ctx) + // transform the url + url.path = url.path.all_after_first(path) + handle_route[T](mut request_app, url, &routes, tid) + } + } +} + // run - start a new VWeb server, listening to all available addresses, at the specified `port` pub fn run[T](global_app &T, port int) { run_at[T](global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) } @@ -415,6 +467,22 @@ pub fn run_at[T](global_app &T, params RunParams) ! { return error('failed to listen ${ecode} ${err}') } + routes := generate_routes(global_app)! + // check duplicate routes in controllers + $if T is ControllerInterface { + mut paths := []string{} + for controller in global_app.controllers { + paths << controller.path + } + for method_name, route in routes { + for controller_path in paths { + if route.path.starts_with(controller_path) { + return error('method "${method_name}" with route "${route.path}" should be handled by the Controller of "${controller_path}"') + } + } + } + } + host := if params.host == '' { 'localhost' } else { params.host } if params.show_startup_message { println('[Vweb] Running app on http://${host}:${params.port}/') @@ -430,18 +498,6 @@ pub fn run_at[T](global_app &T, params RunParams) ! { } flush_stdout() - // Parse the attributes of vweb app methods: - mut routes := map[string]Route{} - $for method in T.methods { - http_methods, route_path, middleware := parse_attrs(method.name, method.attrs) or { - return error('error parsing method attributes: ${err}') - } - routes[method.name] = Route{ - methods: http_methods - path: route_path - middleware: middleware - } - } // Forever accept every connection that comes, and // pass it through the channel, to the thread pool: for { @@ -458,7 +514,7 @@ pub fn run_at[T](global_app &T, params RunParams) ! { } } -fn new_request_app[T](global_app &T) &T { +fn new_request_app[T](global_app &T, ctx Context) &T { // Create a new app object for each connection, copy global data like db connections mut request_app := &T{} $if T is MiddlewareInterface { @@ -484,22 +540,16 @@ fn new_request_app[T](global_app &T) &T { } } } - request_app.Context = global_app.Context // copy the context ref that contains static files map etc + request_app.Context = ctx // copy the context ref that contains static files map etc return request_app } [manualfree] fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, tid int) { - // Create a new app object for each connection, copy global data like db connections - mut app := new_request_app[T](global_app) - conn.set_read_timeout(30 * time.second) conn.set_write_timeout(30 * time.second) defer { conn.close() or {} - unsafe { - free(app) - } } conn.set_sock() or { @@ -531,14 +581,13 @@ fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, dump(req.url) } // URL Parse - url := urllib.parse(req.url) or { + mut url := urllib.parse(req.url) or { eprintln('[vweb] tid: ${tid:03d}, error parsing path: ${err}') return } // Query parse query := parse_query_from_url(url) - url_words := url.path.split('/').filter(it != '') // Form parse form, files := parse_form_from_request(req) or { @@ -547,17 +596,43 @@ fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, return } - app.Context = Context{ + // cut off + ctx := Context{ req: req page_gen_start: page_gen_start conn: conn query: query form: form files: files - static_files: app.static_files - static_mime_types: app.static_mime_types + static_files: global_app.static_files + static_mime_types: global_app.static_mime_types } + // match controller paths + $if T is ControllerInterface { + for controller in global_app.controllers { + if url.path.len >= controller.path.len && url.path.starts_with(controller.path) { + // pass route handling to the controller + controller.handler(ctx, mut url, tid) + return + } + } + } + + mut request_app := new_request_app(global_app, ctx) + handle_route(mut request_app, url, routes, tid) +} + +[manualfree] +fn handle_route[T](mut app T, url urllib.URL, routes &map[string]Route, tid int) { + defer { + unsafe { + free(app) + } + } + + url_words := url.path.split('/').filter(it != '') + // Calling middleware... app.before_request() @@ -589,7 +664,7 @@ fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, } // Skip if the HTTP request method does not match the attributes - if req.method in route.methods { + if app.req.method in route.methods { // Used for route matching route_words := route.path.split('/').filter(it != '') @@ -604,11 +679,11 @@ fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, } } - if req.method == .post && method.args.len > 0 { + if app.req.method == .post && method.args.len > 0 { // Populate method args with form values mut args := []string{cap: method.args.len} for param in method.args { - args << form[param.name] + args << app.form[param.name] } if route.middleware == '' { @@ -662,7 +737,7 @@ fn handle_conn[T](mut conn net.TcpConn, global_app &T, routes &map[string]Route, } } // Route not found - conn.write(vweb.http_404.bytes()) or {} + app.conn.write(vweb.http_404.bytes()) or {} } // validate_middleware validates and fires all middlewares that are defined in the global app instance