diff --git a/examples/xvweb/todo/.gitignore b/examples/xvweb/todo/.gitignore new file mode 100644 index 00000000000000..3997beadf829e6 --- /dev/null +++ b/examples/xvweb/todo/.gitignore @@ -0,0 +1 @@ +*.db \ No newline at end of file diff --git a/examples/xvweb/todo/README.md b/examples/xvweb/todo/README.md new file mode 100644 index 00000000000000..c54ed8486e6305 --- /dev/null +++ b/examples/xvweb/todo/README.md @@ -0,0 +1,19 @@ +# A simple TODO app using x.vweb + +A simple TODO app using `x.vweb` showcasing how to build a basic web app with vweb. + +## Database + +This example uses an sqlite database using the `db.sqlite` package, +but you can use any database from the `db` module. + +## Quick Start + +Run from this directory with +```bash +v run main.v +``` +You can also enable vweb's livereload feature with +```bash +v watch -d vweb_livereload run main.v` +``` \ No newline at end of file diff --git a/examples/xvweb/todo/assets/main.css b/examples/xvweb/todo/assets/main.css new file mode 100644 index 00000000000000..b623f4e1561ce0 --- /dev/null +++ b/examples/xvweb/todo/assets/main.css @@ -0,0 +1,122 @@ + +html, body { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + margin: 0; +} + +body { + max-width: 900px; + padding: 50px; + margin: auto; +} + +* { + box-sizing: border-box; +} + +/* simple styles reset */ +button { + appearance: none; + outline: 0; + border: 0; + margin: 0; + padding: 0; +} + +input { + appearance: none; + outline: 0; + font-size: 16px; + height: 30px; + line-height: 30px; + border: 1px solid #d3d3d3; + border-radius: 5px; +} + +button { + font-size: 14px; + height: 30px; + padding: 5px 20px; + border-radius: 5px; + cursor: pointer; +} + +button.primary { + background-color: #3b71ca; + color: white; +} +button.error { + background-color: red; + color: white; +} +button.success { + background-color: green; + color: white; +} + +.form-success { + color: green; +} + +.form-error { + color: red; +} + +h1 { + text-align: center; +} + +section { + padding: 20px; +} +section.create-todo { + max-width: fit-content; + margin: auto; +} + +.todo-list { + display: flex; + flex-direction: column; +} + +.todo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px; + border-top: 1px solid #d3d3d3; +} + +.todo .name { + flex-grow: 1; + font-weight: bold; +} + +.todo-id, .time { + font-size: 14px; + font-weight: normal; + color: #3d3d3d; + margin: 0px 10px; +} + +/* we're mobile friendly */ +@media only screen and (max-width: 900px) { + body { + max-width: unset; + } + + .todo { + flex-direction: column; + gap: 5px; + } + .todo p { + margin: 0; + } + + section.create-todo form { + display: flex; + flex-direction: column; + gap: 10px; + } +} \ No newline at end of file diff --git a/examples/xvweb/todo/main.v b/examples/xvweb/todo/main.v new file mode 100644 index 00000000000000..68c5b64a156790 --- /dev/null +++ b/examples/xvweb/todo/main.v @@ -0,0 +1,145 @@ +// Simple TODO app using x.vweb +// Run from this directory with `v run main.v` +// You can also enable vwebs livereload feature with +// `v watch -d vweb_livereload run main.v` +module main + +import x.vweb +import db.sqlite +import time + +struct Todo { +pub mut: + // `id` is the primary field. The attribute `sql: serial` acts like AUTO INCREMENT in sql. + // You can use this attribute if you want a unique id for each row. + id int @[primary; sql: serial] + name string + completed bool + created time.Time + updated time.Time +} + +pub struct Context { + vweb.Context +pub mut: + // we can use this field to check whether we just created a TODO in our html templates + created_todo bool +} + +pub struct App { + vweb.StaticHandler +pub: + // we can access the SQLITE database directly via `app.db` + db sqlite.DB +} + +// This method will only handle GET requests to the index page +@[get] +pub fn (app &App) index(mut ctx Context) vweb.Result { + todos := sql app.db { + select from Todo + } or { return ctx.server_error('could not fetch todos from database!') } + + // TODO: use $vweb.html() + return ctx.html($tmpl('templates/index.html')) +} + +// This method will only handle POST requests to the index page +@['/'; post] +pub fn (app &App) create_todo(mut ctx Context, name string) vweb.Result { + // We can receive form input fields as arguments in a route! + // we could also access the name field by doing `name := ctx.form['name']` + + // validate input field + if name.len == 0 { + // set a form error + ctx.form_error = 'You must fill in all the fields!' + // send a HTTP 400 response code indicating that the form fields are incorrect + ctx.res.set_status(.bad_request) + // render the home page + return app.index(mut ctx) + } + + // create a new todo + todo := Todo{ + name: name + created: time.now() + updated: time.now() + } + + // insert the todo into our database + sql app.db { + insert todo into Todo + } or { return ctx.server_error('could not insert a new TODO in the datbase') } + + ctx.created_todo = true + + // render the home page + return app.index(mut ctx) +} + +@['/todo/:id/complete'; post] +pub fn (app &App) complete_todo(mut ctx Context, id int) vweb.Result { + // first check if there exist a TODO record with `id` + todos := sql app.db { + select from Todo where id == id + } or { return ctx.server_error("could not fetch TODO's") } + if todos.len == 0 { + // return HTTP 404 when the TODO does not exist + ctx.res.set_status(.not_found) + return ctx.text('There is no TODO item with id=${id}') + } + + // update the TODO field + sql app.db { + update Todo set completed = true, updated = time.now() where id == id + } or { return ctx.server_error('could not update TODO') } + + // redirect client to the home page and tell the browser to sent a GET request + return ctx.redirect('/', .see_other) +} + +@['/todo/:id/delete'; post] +pub fn (app &App) delete_todo(mut ctx Context, id int) vweb.Result { + // first check if there exist a TODO record with `id` + todos := sql app.db { + select from Todo where id == id + } or { return ctx.server_error("could not fetch TODO's") } + if todos.len == 0 { + // return HTTP 404 when the TODO does not exist + ctx.res.set_status(.not_found) + return ctx.text('There is no TODO item with id=${id}') + } + + // prevent hackers from deleting TODO's that are not completed ;) + to_be_deleted := todos[0] + if to_be_deleted.completed == false { + return ctx.request_error('You must first complete a TODO before you can delete it!') + } + + // delete the todo + sql app.db { + delete from Todo where id == id + } or { return ctx.server_error('could not delete TODO') } + + // redirect client to the home page and tell the browser to sent a GET request + return ctx.redirect('/', .see_other) +} + +fn main() { + // create a new App instance with a connection to the datbase + mut app := &App{ + db: sqlite.connect('todo.db')! + } + + // mount the assets folder at `/assets/` + app.handle_static('assets', false)! + + // create the table in our database, if it doesn't exist + sql app.db { + create table Todo + }! + + // start our app at port 8080 + vweb.run[App, Context](mut app, 8080) +} diff --git a/examples/xvweb/todo/templates/index.html b/examples/xvweb/todo/templates/index.html new file mode 100644 index 00000000000000..b73c6b9051344a --- /dev/null +++ b/examples/xvweb/todo/templates/index.html @@ -0,0 +1,64 @@ + + + + + + My TODO App + + @css '/assets/main.css' + + + +
+

List of all my todos

+
+ +
+ + @if ctx.created_todo +

Created a new todo!

+ @endif + +
+
+ @if todos.len == 0 +

Nothing to see here...

+ @endif + + @for todo in todos +
+

(id: @{todo.id})@{todo.name}

+ @if !todo.completed + +

Created at: @{todo.created.hhmmss()}

+ +
+ +
+ @else +

Completed at: @{todo.updated.hhmmss()}

+

✔️

+ +
+ +
+ @endif +
+ @endfor + +
+
+ +
+

Create a new TODO item

+
+ + + +

@{ctx.form_error}

+
+
+
+ + + \ No newline at end of file diff --git a/vlib/x/vweb/README.md b/vlib/x/vweb/README.md index 84cc83d0f310f0..11cba4fb21e778 100644 --- a/vlib/x/vweb/README.md +++ b/vlib/x/vweb/README.md @@ -141,8 +141,9 @@ pub fn (app &App) login(mut ctx Context) vweb.Result { if password.len < 12 { return ctx.text('password is too weak!') } else { - // redirect to the profile page - return ctx.redirect('/profile') + // we receive a POST request, so we want to explicitly tell the browser + // to send a GET request to the profile page. + return ctx.redirect('/profile', .see_other) } } } @@ -751,13 +752,27 @@ pub fn (app &App) index(mut ctx Context) vweb.Result { #### Redirect +You must pass the type of redirect to vweb: +- `moved_permanently` HTTP code 301 +- `found` HTTP code 302 +- `see_other` HTTP code 303 +- `temporary_redirect` HTTP code 307 +- `permanent_redirect` HTTP code 308 + +**Common use cases:** + +If you want to change the request method, for example when you receive a post request and +want to redirect to another page via a GET request, you should use `see_other`. If you want +the HTTP method to stay the same you should use `found` generally speaking. + **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') + // we explicitly tell the browser to send a GET request + return ctx.redirect('/login', .see_other) } else { return ctx.text('Welcome!') } diff --git a/vlib/x/vweb/context.v b/vlib/x/vweb/context.v index c92a8c6bc372cf..d42ea3ef888bcb 100644 --- a/vlib/x/vweb/context.v +++ b/vlib/x/vweb/context.v @@ -10,6 +10,14 @@ enum ContextReturnType { file } +pub enum RedirectType { + moved_permanently = int(http.Status.moved_permanently) + found = int(http.Status.found) + see_other = int(http.Status.see_other) + temporary_redirect = int(http.Status.temporary_redirect) + permanent_redirect = int(http.Status.permanent_redirect) +} + // 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 { @@ -213,10 +221,12 @@ pub fn (mut ctx Context) server_error(msg string) Result { } // Redirect to an url -pub fn (mut ctx Context) redirect(url string) Result { - ctx.res.set_status(.found) +pub fn (mut ctx Context) redirect(url string, redirect_type RedirectType) Result { + status := http.Status(redirect_type) + ctx.res.set_status(status) + ctx.res.header.add(.location, url) - return ctx.send_response_to_client('text/plain', '302 Found') + return ctx.send_response_to_client('text/plain', status.str()) } // before_request is always the first function that is executed and acts as middleware diff --git a/vlib/x/vweb/tests/vweb_app_test.v b/vlib/x/vweb/tests/vweb_app_test.v index 35227f4a8f8916..b63e78162addd7 100644 --- a/vlib/x/vweb/tests/vweb_app_test.v +++ b/vlib/x/vweb/tests/vweb_app_test.v @@ -51,7 +51,7 @@ pub fn (mut app App) new_article(mut ctx Context) vweb.Result { insert article into Article } or {} - return ctx.redirect('/') + return ctx.redirect('/', .see_other) } pub fn (mut app App) time(mut ctx Context) vweb.Result {