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'
+
+
+
+
+
+
+
+ @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
+
+
+
+
+
+
\ 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 {