This commit is contained in:
Casper Küthe 2023-12-09 06:07:51 +01:00 committed by GitHub
parent 2768de1fc7
commit 08189d649c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 4001 additions and 24 deletions

View file

@ -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'

View file

@ -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',

View file

@ -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()
}

View file

@ -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)

View file

@ -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

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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'])
}

View 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
}

View 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!'
}

View 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)!
}

View 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

View 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
View file

@ -0,0 +1 @@
root

View file

@ -0,0 +1 @@
sub

View file

@ -0,0 +1 @@
unkown_mime

View 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')
}

View 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()
}

View 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
View 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)!
}

View 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)
}