From 0a6be87970fd7553eb664800c07e3822c9844564 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Mon, 28 Mar 2022 14:24:26 +0200 Subject: [PATCH] Start of web framework revamp --- src/server/git.v | 37 ++++---- src/server/response.v | 27 ++++++ src/server/routes.v | 18 ++-- src/web/web.v | 204 ++++-------------------------------------- 4 files changed, 70 insertions(+), 216 deletions(-) create mode 100644 src/server/response.v diff --git a/src/server/git.v b/src/server/git.v index 684e75a8..0743ecc3 100644 --- a/src/server/git.v +++ b/src/server/git.v @@ -78,31 +78,31 @@ fn write_repos(path string, repos &map[string]GitRepo) ? { ['/api/repos'; get] fn (mut app App) get_repos() web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.server_error(500) + return app.status(500) } } - return app.json(repos) + return app.json(200, new_data_response(repos)) } ['/api/repos/:id'; get] fn (mut app App) get_single_repo(id string) web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.server_error(500) + return app.status(500) } } @@ -112,18 +112,17 @@ fn (mut app App) get_single_repo(id string) web.Result { repo := repos[id] - return app.json(repo) + return app.json(200, new_data_response(repo)) } ['/api/repos'; post] fn (mut app App) post_repo() web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } new_repo := repo_from_params(app.query) or { - app.set_status(400, err.msg) - return app.ok("") + return app.json(400, new_response(err.msg)) } id := rand.uuid_v4() @@ -132,37 +131,37 @@ fn (mut app App) post_repo() web.Result { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.server_error(500) + return app.status(500) } } // We need to check for duplicates for _, repo in repos { if repo == new_repo { - return app.text('Duplicate repository.') + return app.json(400, new_response('Duplicate repository.')) } } repos[id] = new_repo lock app.git_mutex { - write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } + write_repos(app.conf.repos_file, &repos) or { return app.status(500) } } - return app.ok('Repo added successfully.') + return app.json(200, new_response('Repo added successfully.')) } ['/api/repos/:id'; delete] fn (mut app App) delete_repo(id string) web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } mut repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.server_error(500) + return app.status(500) } } @@ -176,20 +175,20 @@ fn (mut app App) delete_repo(id string) web.Result { write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } } - return app.ok('Repo removed successfully.') + return app.json(200, new_response('Repo removed successfully.')) } ['/api/repos/:id'; patch] fn (mut app App) patch_repo(id string) web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } mut repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.server_error(500) + return app.status(500) } } @@ -203,5 +202,5 @@ fn (mut app App) patch_repo(id string) web.Result { write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } } - return app.ok('Repo updated successfully.') + return app.json(200, new_response('Repo updated successfully.')) } diff --git a/src/server/response.v b/src/server/response.v new file mode 100644 index 00000000..376493f2 --- /dev/null +++ b/src/server/response.v @@ -0,0 +1,27 @@ +module server + +struct Response { + message string + data T +} + +fn new_response(message string) Response { + return Response{ + message: message + data: "" + } +} + +fn new_data_response(data T) Response { + return Response{ + message: "" + data: data + } +} + +fn new_full_response(message string, data T) Response { + return Response{ + message: message + data: data + } +} diff --git a/src/server/routes.v b/src/server/routes.v index 0090666e..ef5d5a55 100644 --- a/src/server/routes.v +++ b/src/server/routes.v @@ -12,7 +12,7 @@ import net.http // server is still responsive. ['/health'; get] pub fn (mut app App) healthcheck() web.Result { - return app.text('Healthy') + return app.json(200, new_response('Healthy.')) } // get_root handles a GET request for a file on the root @@ -31,7 +31,7 @@ fn (mut app App) get_root(filename string) web.Result { // Scuffed way to respond to HEAD requests if app.req.method == http.Method.head { if os.exists(full_path) { - return app.ok('') + return app.status(200) } return app.not_found() @@ -43,7 +43,7 @@ fn (mut app App) get_root(filename string) web.Result { ['/publish'; post] fn (mut app App) put_package() web.Result { if !app.is_authorized() { - return app.text('Unauthorized.') + return app.json(401, new_response('Unauthorized.')) } mut pkg_path := '' @@ -64,14 +64,16 @@ fn (mut app App) put_package() web.Result { util.reader_to_file(mut app.reader, length.int(), pkg_path) or { app.lwarn("Failed to upload '$pkg_path'") - return app.text('Failed to upload file.') + return app.json(500, new_response('Failed to upload file.')) } sw.stop() app.ldebug("Upload of '$pkg_path' completed in ${sw.elapsed().seconds():.3}s.") } else { app.lwarn('Tried to upload package without specifying a Content-Length.') - return app.text("Content-Type header isn't set.") + + // length required + return app.status(411) } res := app.repo.add_from_path(pkg_path) or { @@ -79,17 +81,17 @@ fn (mut app App) put_package() web.Result { os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } - return app.text('Failed to add package.') + return app.json(500, new_response('Failed to add package.')) } if !res.added { os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } app.lwarn("Duplicate package '$res.pkg.full_name()'.") - return app.text('File already exists.') + return app.json(400, new_response('File already exists.')) } app.linfo("Added '$res.pkg.full_name()' to repository.") - return app.text('Package added successfully.') + return app.json(200, new_response('Package added successfully.')) } diff --git a/src/web/web.v b/src/web/web.v index ad647f20..4e931899 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -12,9 +12,6 @@ import time import json import log -// A type which don'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 {} @@ -142,7 +139,7 @@ pub const ( pub struct Context { mut: content_type string = 'text/plain' - status string = '200 OK' + status int = 200 pub: // HTTP Request req http.Request @@ -186,24 +183,14 @@ struct Route { path string } -// Defining this method is optional. -// init_server is called at server start. -// You can use it for initializing globals. -pub fn (ctx Context) init_server() { - eprintln('init_server() has been deprecated, please init your web app in `fn main()`') -} - // Defining this method is optional. // before_request is called before every request (aka middleware). // Probably you can use it for check user session cookie or add header. pub fn (ctx Context) before_request() {} -pub struct Cookie { - name string - value string - expires time.Time - secure bool - http_only bool +// send_string +fn send_string(mut conn net.TcpConn, s string) ? { + conn.write(s.bytes()) ? } // send_response_to_client sends a response to the client @@ -225,33 +212,24 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bo text: res } resp.set_version(.v1_1) - resp.set_status(http.status_from_int(ctx.status.int())) + resp.set_status(http.status_from_int(ctx.status)) send_string(mut ctx.conn, resp.bytestr()) or { return false } return true } -// html HTTP_OK with s as payload with content-type `text/html` -pub fn (mut ctx Context) html(s string) Result { - ctx.send_response_to_client('text/html', s) - return Result{} -} +pub fn (mut ctx Context) text(status int, s string) Result { + ctx.status = status -// text HTTP_OK with s as payload with content-type `text/plain` -pub fn (mut ctx Context) text(s string) Result { ctx.send_response_to_client('text/plain', s) + return Result{} } // json HTTP_OK with json_s as payload with content-type `application/json` -pub fn (mut ctx Context) json(j T) Result { - json_s := json.encode(j) - ctx.send_response_to_client('application/json', json_s) - return Result{} -} +pub fn (mut ctx Context) json(status int, j T) Result { + ctx.status = status -// json_pretty Response HTTP_OK with a pretty-printed JSON result -pub fn (mut ctx Context) json_pretty(j T) Result { - json_s := json.encode_pretty(j) + json_s := json.encode(j) ctx.send_response_to_client('application/json', json_s) return Result{} } @@ -302,7 +280,7 @@ pub fn (mut ctx Context) file(f_path string) Result { header: header.join(web.headers_close) } resp.set_version(.v1_1) - resp.set_status(http.status_from_int(ctx.status.int())) + resp.set_status(http.status_from_int(ctx.status)) send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } mut buf := []byte{len: 1_000_000} @@ -328,10 +306,8 @@ pub fn (mut ctx Context) file(f_path string) Result { return Result{} } -// ok Response HTTP_OK with s as payload -pub fn (mut ctx Context) ok(s string) Result { - ctx.send_response_to_client(ctx.content_type, s) - return Result{} +pub fn (mut ctx Context) status(status int) Result { + return ctx.text(status, '') } // server_error Response a server error @@ -361,64 +337,7 @@ pub fn (mut ctx Context) redirect(url string) Result { // not_found Send an not_found response pub fn (mut ctx Context) not_found() Result { - if ctx.done { - return Result{} - } - ctx.done = true - send_string(mut ctx.conn, web.http_404.bytestr()) or {} - return Result{} -} - -// set_cookie Sets a cookie -pub fn (mut ctx Context) set_cookie(cookie Cookie) { - mut cookie_data := []string{} - mut secure := if cookie.secure { 'Secure;' } else { '' } - secure += if cookie.http_only { ' HttpOnly' } else { ' ' } - cookie_data << secure - if cookie.expires.unix > 0 { - cookie_data << 'expires=$cookie.expires.utc_string()' - } - data := cookie_data.join(' ') - ctx.add_header('Set-Cookie', '$cookie.name=$cookie.value; $data') -} - -// set_content_type Sets the response content type -pub fn (mut ctx Context) set_content_type(typ string) { - ctx.content_type = typ -} - -// set_cookie_with_expire_date Sets a cookie with a `expire_data` -pub fn (mut ctx Context) set_cookie_with_expire_date(key string, val string, expire_date time.Time) { - ctx.add_header('Set-Cookie', '$key=$val; Secure; HttpOnly; expires=$expire_date.utc_string()') -} - -// get_cookie Gets a cookie by a key -pub fn (ctx &Context) get_cookie(key string) ?string { // TODO refactor - mut cookie_header := ctx.get_header('cookie') - if cookie_header == '' { - cookie_header = ctx.get_header('Cookie') - } - cookie_header = ' ' + cookie_header - // println('cookie_header="$cookie_header"') - // println(ctx.req.header) - cookie := if cookie_header.contains(';') { - cookie_header.find_between(' $key=', ';') - } else { - cookie_header.find_between(' $key=', '\r') - } - if cookie != '' { - return cookie.trim_space() - } - return error('Cookie not found') -} - -// set_status Sets the response status -pub fn (mut ctx Context) set_status(code int, desc string) { - if code < 100 || code > 599 { - ctx.status = '500 Internal Server Error' - } else { - ctx.status = '$code $desc' - } + return ctx.status(404) } // add_header Adds an header to the response with key and val @@ -560,12 +479,6 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { // Calling middleware... app.before_request() - // Static handling - if serve_if_static(mut app, url) { - // successfully served a static file - return - } - // Route matching $for method in T.methods { $if method.return_type is Result { @@ -661,83 +574,6 @@ fn route_matches(url_words []string, route_words []string) ?[]string { return params } -// serve_if_static checks 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(mut app T, url urllib.URL) bool { - // TODO: handle url parameters properly - for now, ignore them - static_file := app.static_files[url.path] - mime_type := app.static_mime_types[url.path] - if static_file == '' || mime_type == '' { - return false - } - data := os.read_file(static_file) or { - send_string(mut app.conn, web.http_404.bytestr()) or {} - return true - } - app.send_response_to_client(mime_type, data) - unsafe { data.free() } - return true -} - -// scan_static_directory makes a static route for each file in a directory -fn (mut ctx Context) scan_static_directory(directory_path string, mount_path string) { - files := os.ls(directory_path) or { panic(err) } - if files.len > 0 { - for file in files { - full_path := os.join_path(directory_path, file) - if os.is_dir(full_path) { - ctx.scan_static_directory(full_path, mount_path + '/' + file) - } else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') { - ext := os.file_ext(file) - // Rudimentary guard against adding files not in mime_types. - // Use serve_static directly to add non-standard mime types. - if ext in web.mime_types { - ctx.serve_static(mount_path + '/' + file, full_path) - } - } - } - } -} - -// handle_static Handles a directory static -// If `root` is set the mount path for the dir will be in '/' -pub fn (mut ctx Context) handle_static(directory_path string, root bool) bool { - if ctx.done || !os.exists(directory_path) { - return false - } - dir_path := directory_path.trim_space().trim_right('/') - mut mount_path := '' - if dir_path != '.' && os.is_dir(dir_path) && !root { - // Mount point hygene, "./assets" => "/assets". - mount_path = '/' + dir_path.trim_left('.').trim('/') - } - ctx.scan_static_directory(dir_path, mount_path) - return true -} - -// mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path -// For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'), -// and you have a file /var/share/myassets/main.css . -// => That file will be available at URL: http://server/assets/main.css . -pub fn (mut ctx Context) mount_static_folder_at(directory_path string, mount_path string) bool { - if ctx.done || mount_path.len < 1 || mount_path[0] != `/` || !os.exists(directory_path) { - return false - } - dir_path := directory_path.trim_right('/') - ctx.scan_static_directory(dir_path, mount_path[1..]) - return true -} - -// serve_static Serves a file static -// `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type -pub fn (mut ctx Context) serve_static(url string, file_path string) { - ctx.static_files[url] = file_path - // ctx.static_mime_types[url] = mime_type - ext := os.file_ext(file_path) - ctx.static_mime_types[url] = web.mime_types[ext] -} - // ip Returns the ip address from the current user pub fn (ctx &Context) ip() string { mut ip := ctx.req.header.get(.x_forwarded_for) or { '' } @@ -760,16 +596,6 @@ pub fn (mut ctx Context) error(s string) { ctx.form_error = s } -// not_found Returns an empty result -pub fn not_found() Result { - return Result{} -} - -// send_string -fn send_string(mut conn net.TcpConn, s string) ? { - conn.write(s.bytes()) ? -} - // filter Do not delete. // It used by `vlib/v/gen/c/str_intp.v:130` for string interpolation inside web templates // TODO: move it to template render