mirror of
https://github.com/vlang/v.git
synced 2025-09-13 22:42:26 +03:00
vweb2 (#19997)
This commit is contained in:
parent
2768de1fc7
commit
08189d649c
27 changed files with 4001 additions and 24 deletions
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
module picoev
|
||||
|
||||
import net
|
||||
import net.conv
|
||||
import picohttpparser
|
||||
|
||||
#include <errno.h>
|
||||
|
@ -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)
|
||||
|
|
833
vlib/x/vweb/README.md
Normal file
833
vlib/x/vweb/README.md
Normal file
|
@ -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('<h1>Login page</h1><p>todo: make form</p>')
|
||||
} 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('<h1>Page not found!</h1>')
|
||||
}
|
||||
```
|
||||
|
||||
## 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('<h1>Hello world!</h1>')
|
||||
// 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)
|
||||
}
|
||||
```
|
287
vlib/x/vweb/context.v
Normal file
287
vlib/x/vweb/context.v
Normal file
|
@ -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('</html>', '<script src="/vweb_livereload/${vweb_livereload_server_start}/script.js"></script>\n</html>')
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
111
vlib/x/vweb/controller.v
Normal file
111
vlib/x/vweb/controller.v
Normal file
|
@ -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
|
||||
}
|
171
vlib/x/vweb/middleware.v
Normal file
171
vlib/x/vweb/middleware.v
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
93
vlib/x/vweb/parse.v
Normal file
93
vlib/x/vweb/parse.v
Normal file
|
@ -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{}
|
||||
}
|
282
vlib/x/vweb/route_test.v
Normal file
282
vlib/x/vweb/route_test.v
Normal file
|
@ -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'])
|
||||
}
|
115
vlib/x/vweb/static_handler.v
Normal file
115
vlib/x/vweb/static_handler.v
Normal file
|
@ -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
|
||||
}
|
130
vlib/x/vweb/tests/controller_test.v
Normal file
130
vlib/x/vweb/tests/controller_test.v
Normal file
|
@ -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!'
|
||||
}
|
122
vlib/x/vweb/tests/large_payload_test.v
Normal file
122
vlib/x/vweb/tests/large_payload_test.v
Normal file
|
@ -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)!
|
||||
}
|
127
vlib/x/vweb/tests/middleware_test.v
Normal file
127
vlib/x/vweb/tests/middleware_test.v
Normal file
|
@ -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
|
100
vlib/x/vweb/tests/static_handler_test.v
Normal file
100
vlib/x/vweb/tests/static_handler_test.v
Normal file
|
@ -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'
|
||||
}
|
1
vlib/x/vweb/tests/testdata/root.txt
vendored
Normal file
1
vlib/x/vweb/tests/testdata/root.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
root
|
1
vlib/x/vweb/tests/testdata/sub_folder/sub.txt
vendored
Normal file
1
vlib/x/vweb/tests/testdata/sub_folder/sub.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
sub
|
1
vlib/x/vweb/tests/testdata/unkown_mime.what
vendored
Normal file
1
vlib/x/vweb/tests/testdata/unkown_mime.what
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
unkown_mime
|
114
vlib/x/vweb/tests/vweb_app_test.v
Normal file
114
vlib/x/vweb/tests/vweb_app_test.v
Normal file
|
@ -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')
|
||||
}
|
341
vlib/x/vweb/tests/vweb_test.v
Normal file
341
vlib/x/vweb/tests/vweb_test.v
Normal file
|
@ -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('<h1>ok</h1>')
|
||||
}
|
||||
|
||||
// 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 == '<h1>ok</h1>'
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
144
vlib/x/vweb/tests/vweb_test_server.v
Normal file
144
vlib/x/vweb/tests/vweb_test_server.v
Normal file
|
@ -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('<h1>ok</h1>')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
902
vlib/x/vweb/vweb.v
Normal file
902
vlib/x/vweb/vweb.v
Normal file
|
@ -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)!
|
||||
}
|
48
vlib/x/vweb/vweb_livereload.v
Normal file
48
vlib/x/vweb/vweb_livereload.v
Normal file
|
@ -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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue