Add routing

This commit is contained in:
Hugo Mårdbrink 2025-11-09 11:11:15 +01:00
parent 1368b7b69b
commit 4316fb2a73
7 changed files with 534 additions and 109 deletions

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "radix_tree"]
path = radix_tree
url = https://codeberg.org/hugomardbrink/odin-radixtree
update = none

100
README.md
View file

@ -1 +1,99 @@
I'll get back to this when Odin is more mature, I've ran into countless compiler seg faults and can't be bothered to find out why anymore.
# HTTP 1/1 server library
## Routing rules
- Static first routing
- Path variables defined with `:foo`
- Example: `/odin/:version/download`
- Wild card routing not implemented
## Compile time user errors included
When defining the server you can pass a custom error type for the server.
All the handler function will, of course, need to return these in as a pair with a response.
## Memory allocation
The server will free the context temp allocators memory after the handler is run, thus, you are free
to use `context.temp_allocator` within your handlers and expect the memory to be freed after.
This becomes useful when you want to pass the response back to the server but is also ergonomic
if you find yourself in need of a temp allocator in your handlers.
## Example
```odin
package main
import "core:log"
import "core:net"
import "core:strings"
import fjord "fjord"
import http "fjord/http"
// Handler for GET /hello/:name
hello_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
name := request.path_variables["name"]
body := strings.concatenate(
{"<h1>Hello, ", name, "!</h1>"},
context.temp_allocator,
)
response := http.make_response(
.Ok,
transmute([]byte)body,
.Html,
context.temp_allocator,
)
return response, nil
}
// Handler for GET /users/:id/posts/:post_id
user_post_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
user_id := request.path_variables["id"]
post_id := request.path_variables["post_id"]
body := strings.concatenate(
{"<p>User ", user_id, " - Post ", post_id, "</p>"},
context.temp_allocator,
)
response := http.make_response(
.Ok,
transmute([]byte)body,
.Html,
context.temp_allocator,
)
return response, nil
}
UserError :: enum {
DatabaseError,
ValidationError,
}
main :: proc() {
context.logger = log.create_console_logger(.Info)
defer log.destroy_console_logger(context.logger)
endpoint := net.Endpoint {
address = net.IP4_Address{127, 0, 0, 1},
port = 8080,
}
server: fjord.Server(UserError)
fjord.server_init(&server, endpoint, context.allocator)
defer fjord.server_destroy(&server)
fjord.server_add_route(&server, .GET, {"hello", ":name"}, hello_handler)
fjord.server_add_route(&server, .GET, {"users", ":id", "posts", ":post_id"}, user_post_handler)
log.infof("Server listening on http://127.0.0.1:%d", endpoint.port)
fjord.listen_and_serve(&server)
}
```
## Dev
Run the test suite to check functionality, curl is a required dependancy for the test suite to work.
```bash
odin test .
```

View file

@ -1,30 +1,75 @@
package fjord
import "core:fmt"
import "core:log"
import "core:net"
import "core:os/os2"
import "core:strings"
import "core:thread"
import "core:time"
import "core:testing"
import http "http"
Error :: enum {}
Error :: enum {
TestError,
}
handler :: proc(request: ^http.Request) -> (http.Response, Error) {
body := "<div>Hello</div>"
response := http.make_response(.Ok, transmute([]byte)body, .Html)
first_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
entity := request.path_variables["entity"]
body := strings.concatenate(
{"<div>Hello first", entity, "</div>"},
context.temp_allocator,
)
response := http.make_response(
.Ok,
transmute([]byte)body,
.Html,
context.temp_allocator,
)
return response, nil
}
get_path_var_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
body := "<div>Hello</div>"
response := http.make_response(.Ok, transmute([]byte)body, .Html)
second_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
entity := request.path_variables["entity"]
body := strings.concatenate(
{"<div>Hello second", entity, "</div>"},
context.temp_allocator,
)
response := http.make_response(
.Ok,
transmute([]byte)body,
.Html,
context.temp_allocator,
)
return response, nil
}
third_handler :: proc(request: ^http.Request) -> (http.Response, Error) {
entity := request.path_variables["entity"]
other_entity := request.path_variables["other_entity"]
body := strings.concatenate(
{"<div>Hello third", entity, "and", other_entity, "</div>"},
context.temp_allocator,
)
response := http.make_response(
.Ok,
transmute([]byte)body,
.Html,
context.temp_allocator,
)
return response, nil
}
@(test)
test_basic_ok :: proc(t: ^testing.T) {
test_router_ok :: proc(t: ^testing.T) {
context.logger = log.create_console_logger(.Info)
defer log.destroy_console_logger(context.logger)
@ -37,7 +82,170 @@ test_basic_ok :: proc(t: ^testing.T) {
server_init(&server, endpoint, context.allocator)
defer server_destroy(&server)
server_add_handler(&server, .GET, {}, handler)
server_add_handler(&server, .GET, {"hello", "{name-thing}"}, handler)
listen_and_serve(&server)
server_add_route(&server, .GET, {"hello", ":entity"}, first_handler)
server_add_route(
&server,
.GET,
{"hello", ":entity", "only"},
second_handler,
)
server_add_route(
&server,
.GET,
{"hello", ":entity", "and", ":other_entity"},
third_handler,
)
handler, path_vars, ok := router_lookup(
&server.router,
.GET,
{"hello", "world"},
context.temp_allocator,
); assert(ok)
assert(handler == first_handler)
assert(path_vars["entity"] == "world")
handler, path_vars, ok = router_lookup(
&server.router,
.GET,
{"hello", "lonely world", "only"},
context.temp_allocator,
); assert(ok)
assert(handler == second_handler)
assert(path_vars["entity"] == "lonely world")
handler, path_vars, ok = router_lookup(
&server.router,
.GET,
{"hello", "world", "and", "worlds friend"},
context.temp_allocator,
); assert(ok)
assert(handler == third_handler)
assert(path_vars["entity"] == "world")
assert(path_vars["other_entity"] == "worlds friend")
ok = server_remove_route(&server, .GET, {"hello", ":entity"}); assert(ok)
ok = server_remove_route(&server, .GET, {"hello", ":entity"}); assert(!ok)
free_all(context.temp_allocator)
}
start_concurrent_server :: proc(
server: ^Server($Error_Type),
) -> ^thread.Thread {
ServerThreadData :: struct {
server: ^Server(Error_Type),
thread: ^thread.Thread,
}
server_thread_proc :: proc(t: ^thread.Thread) {
d := (^ServerThreadData)(t.data)
listen_and_serve(d.server)
}
d := new(ServerThreadData, context.temp_allocator)
d.server = server
if d.thread = thread.create(server_thread_proc); d.thread != nil {
d.thread.init_context = context
d.thread.data = rawptr(d)
thread.start(d.thread)
}
// Give server some time to start
time.sleep(100 * time.Millisecond)
return d.thread
}
shutdown_concurrent_server :: proc(
server: ^Server($Error_Type),
t: ^thread.Thread,
) {
server_shutdown(server)
// Send a dummy request to unblock accept_tcp, todo: fix this immidiate server shutdown
_, _, _, err := os2.process_exec(
{command = []string{"curl", "-s", "http://127.0.0.1:8080/dummy"}},
context.temp_allocator,
)
assert(err == nil)
thread.join(t)
thread.destroy(t)
}
assert_endpoint :: proc(url: string, expected_response: string) {
state, stdout, stderr, err := os2.process_exec(
{command = []string{"curl", "-s", url}},
context.temp_allocator,
)
if err != nil {
log.errorf("Failed to execute curl: %v", err)
assert(false, "curl execution failed")
return
}
if state.exit_code != 0 {
log.errorf("curl failed with exit code: %d", state.exit_code)
assert(false, "curl returned non-zero exit code")
return
}
response := string(stdout)
log.assertf(
response == expected_response,
"Expected: %s, but got: %s",
response,
expected_response,
)
}
@(test)
test_server_general_ok :: proc(t: ^testing.T) {
context.logger = log.create_console_logger(.Info)
defer log.destroy_console_logger(context.logger)
endpoint := net.Endpoint {
address = net.IP4_Address{127, 0, 0, 1},
port = 8080,
}
server: Server(Error)
server_init(&server, endpoint, context.allocator)
defer server_destroy(&server)
server_add_route(&server, .GET, {"hello", ":entity"}, first_handler)
server_add_route(
&server,
.GET,
{"hello", ":entity", "only"},
second_handler,
)
server_add_route(
&server,
.GET,
{"hello", ":entity", "and", ":other_entity"},
third_handler,
)
t := start_concurrent_server(&server)
assert_endpoint(
"http://127.0.0.1:8080/hello/world",
"<div>Hello firstworld</div>",
)
assert_endpoint(
"http://127.0.0.1:8080/hello/lonely%20world/only",
"<div>Hello secondlonely%20world</div>",
)
assert_endpoint(
"http://127.0.0.1:8080/hello/world/and/worlds%20friend",
"<div>Hello thirdworldandworlds%20friend</div>",
)
shutdown_concurrent_server(&server, t)
free_all(context.temp_allocator)
}

View file

@ -55,13 +55,14 @@ status_text :: proc(status: Status) -> string {
unmarshall_request_line :: proc(
request: ^Request,
reader: ^bufio.Reader,
allocator := context.temp_allocator
) -> RequestError {
request_line, io_err := bufio.reader_read_slice(reader, '\n')
if io_err != nil do return .InvalidRequestLine
request_line = sanitize_byte_slice(request_line)
parts := bytes.split(request_line, {' '})
parts := bytes.split(request_line, {' '}, allocator)
if len(parts) != 3 do return .InvalidRequestLine
request.method = method_from_str(string(parts[0]))
@ -86,7 +87,7 @@ unmarshall_request_headers :: proc(
header_line = sanitize_byte_slice(header_line)
if len(header_line) == 0 do break
parts := bytes.split_n(header_line, {':'}, 2)
parts := bytes.split_n(header_line, {':'}, 2, allocator)
if len(parts) != 2 do return .InvalidHeaderFormat
key := string(bytes.trim_space(parts[0]))

@ -1 +0,0 @@
Subproject commit 6fe8dc79ee3aa57f3a8b9a427bedf7bb24cfd809

View file

@ -1,42 +1,188 @@
#+private package
package fjord
import "base:runtime"
import "core:path/slashpath"
import "core:strings"
import http "http"
import rxt "radix_tree"
EndpointHandler :: struct($Error_Type: typeid) {
method_handlers: map[http.Method]#type proc(
PathVariable :: struct {
name: string,
}
Static :: struct {}
RouterNodeType :: union {
Static,
PathVariable,
}
RouterNode :: struct($Error_Type: typeid) {
type: RouterNodeType,
handlers: map[http.Method]#type proc(request: ^http.Request) -> (http.Response, Error_Type),
children: map[string]^RouterNode(Error_Type),
}
Router :: struct($Error_Type: typeid) {
root_node: ^RouterNode(Error_Type),
allocator: runtime.Allocator,
}
@(private = "file")
router_node_init :: proc(
router: ^Router($Error_Type),
router_node: ^RouterNode(Error_Type),
maybe_path_var_name: Maybe(string),
) {
router_node.children = make(
map[string]^RouterNode(Error_Type),
router.allocator,
)
router_node.handlers = make(
map[http.Method]#type proc(
request: ^http.Request,
) -> (
http.Response,
Error_Type,
),
path_variables: []string,
router.allocator,
)
path_var_name, ok := maybe_path_var_name.?
if ok {
router_node.type = PathVariable {
name = path_var_name,
}
} else {
router_node.type = Static{}
}
}
Router :: struct {
radix_tree: rxt.RadixTree(EndpointHandler),
router_init :: proc(
router: ^Router($Error_Type),
allocator: runtime.Allocator,
) {
router^ = Router(Error_Type) {
root_node = new(RouterNode(Error_Type), allocator),
allocator = allocator,
}
router_node_init(router, router.root_node, nil)
}
router_init :: proc(router: ^Router, allocator := context.allocator) {
rxt.init(&router.radix_tree, allocator)
router_destroy :: proc(router: ^Router($Error_Type)) {
stack := make([dynamic]^RouterNode(Error_Type), router.allocator)
defer delete(stack)
append(&stack, router.root_node)
for len(stack) > 0 {
node := pop(&stack)
for _, child in node.children {
append(&stack, child)
}
delete(node.handlers)
delete(node.children)
free(node)
}
}
router_destroy :: proc(router : ^Router) {
rxt.destroy(&router.radix_tree)
router_lookup :: proc(
router: ^Router($Error_Type),
method: http.Method,
path: []string,
allocator := context.temp_allocator,
) -> (
handler: #type proc(request: ^http.Request) -> (http.Response, Error_Type),
path_variables: map[string]string,
ok: bool,
) {
path_variables = make(map[string]string, allocator)
current_node := router.root_node
for segment_value in path {
if child, found := current_node.children[segment_value]; found {
current_node = child
continue
}
maybe_path_child: Maybe(^RouterNode(Error_Type)) = nil
for name, child in current_node.children {
switch type in child.type {
case PathVariable:
maybe_path_child = child
path_variables[type.name] = segment_value
break
case Static:
}
}
if path_child, ok := maybe_path_child.?; ok {
current_node = path_child
} else {
return handler, path_variables, false
}
}
if handler, ok := current_node.handlers[method]; ok {
return handler, path_variables, true
} else {
return handler, path_variables, false
}
}
router_lookup :: proc(router: ^Router, key: string) -> (endpoint_handler: EndpointHandler, ok: bool) {
return rxt.lookup(&router.radix_tree, key)
router_add_route :: proc(
router: ^Router($Error_Type),
method: http.Method,
path: []string,
handler: #type proc(request: ^http.Request) -> (http.Response, Error_Type),
) {
current_node := router.root_node
for segment_name in path {
node, ok := current_node.children[segment_name]
if !ok {
new_node := new(RouterNode(Error_Type))
if strings.has_prefix(segment_name, ":") {
router_node_init(router, new_node, segment_name[1:])
} else {
router_node_init(router, new_node, nil)
}
current_node.children[segment_name] = new_node
current_node = new_node
continue
}
current_node = node
}
current_node.handlers[method] = handler
}
router_add_route :: proc(router: ^Router, key: []string, value: EndpointHandler) {
joined_path := slashpath.join(key, context.allocator)
defer delete(joined_path)
router_remove_route :: proc(
router: ^Router($Error_Type),
method: http.Method,
path: []string,
) -> (
ok: bool,
) {
current_node := router.root_node
rxt.insert(&router.radix_tree, joined_path[:], value)
}
for segment_name in path {
node, ok := current_node.children[segment_name]
if !ok do return false
current_node = node
}
router_remove_route :: proc(router: ^Router, key: string) -> (ok: bool){
return rxt.remove(&router.radix_tree, key)
if method not_in current_node.handlers {
return false
}
delete_key(&current_node.handlers, method)
return true
}

View file

@ -8,19 +8,21 @@ import "core:net"
import "core:path/slashpath"
import "core:strconv"
import "core:strings"
import "core:sync"
import "core:unicode"
import http "http"
Server :: struct($Error_Type: typeid) {
endpoint: net.Endpoint,
router: Router(EndpointHandler(Error_Type)),
not_found_handler: #type proc(
request: ^http.Request,
) -> (
http.Response,
Error_Type,
),
router: Router(Error_Type),
shutdown: bool,
}
@(private)
@ -75,62 +77,16 @@ server_init :: proc(
)
return http_response, nil
},
shutdown = false,
}
router_init(&server.router)
router_init(&server.router, allocator)
}
server_destroy :: proc(server: ^Server($Error_Type)) {
router_destroy(&server.router)
}
server_add_handler :: proc(
server: ^Server($Error_Type),
method: http.Method,
path: []string,
handle_proc: #type proc(
request: ^http.Request,
) -> (
http.Response,
Error_Type,
),
) {
path_variables := make([dynamic]string, context.allocator)
defer delete(path_variables)
for seg in path {
is_path_var :=
strings.has_prefix(seg, "{") && strings.has_suffix(seg, "}")
if is_path_var {
append(&path_variables, seg[1:len(seg) - 1])
}
}
joined_path := slashpath.join(path, context.allocator)
defer delete(joined_path)
if joined_path == "" {
router_add_route(
&server.router,
"/",
EndpointHandler(Error_Type){method},
)
server.handlers["/"] = EndpointHandler(Error_Type) {
method,
handle_proc,
path_variables[:],
}
} else {
server.handlers[joined_path] = Handler(Error_Type) {
method,
handle_proc,
path_variables[:],
}
}
}
// This is used instead of server_make param until compiler bug is fixed: https://github.com/odin-lang/Odin/issues/5792
// This is used instead of default server_make param until compiler bug is fixed: https://github.com/odin-lang/Odin/issues/5792
server_set_not_found_handler :: proc(
server: ^Server($Error_Type),
not_found_handler: #type proc(
@ -143,22 +99,27 @@ server_set_not_found_handler :: proc(
server.not_found_handler = not_found_handler
}
match_handler_pattern :: proc(
server_add_route :: proc(
server: ^Server($Error_Type),
method: http.Method,
path: string,
allocator := context.allocator,
) -> (
path: []string,
handler: #type proc(request: ^http.Request) -> (http.Response, Error_Type),
path_variables: map[string]string,
) {
path_variables = make(map[string]string, allocator)
segments := strings.split(path, "/", context.allocator)
defer delete(segments)
router_add_route(&server.router, method, path, handler)
}
handler = server.handlers[identifier].procedure
server_remove_route :: proc(
server: ^Server($Error_Type),
method: http.Method,
path: []string,
) -> (
ok: bool,
) {
return router_remove_route(&server.router, method, path)
}
return handler, path_variables
server_shutdown :: proc(server: ^Server($Error_Type)) {
sync.atomic_store(&server.shutdown, true)
}
listen_and_serve :: proc(server: ^Server($Error_Type)) {
@ -166,7 +127,7 @@ listen_and_serve :: proc(server: ^Server($Error_Type)) {
log.assert(net_err == nil, "Couldn't create TCP socket")
defer net.close(server_socket)
for {
for !sync.atomic_load(&server.shutdown) {
client_socket, source, net_err := net.accept_tcp(server_socket)
if net_err != nil {
log.warnf("Failed to accept TCP connection, reason: %s", net_err)
@ -201,13 +162,29 @@ listen_and_serve :: proc(server: ^Server($Error_Type)) {
http.Response,
Error_Type,
)
handler, http_request.path_variables = match_handler_pattern(
server,
http_request.method,
http_request.path,
)
ok: bool
http_response, err := handler(&http_request)
// todo: should sanitize better
path := strings.split(
http_request.path,
"/",
context.temp_allocator,
)[1:]
handler, http_request.path_variables, ok = router_lookup(
&server.router,
http_request.method,
path,
context.temp_allocator,
)
if !ok do handler = server.not_found_handler
http_response, handler_err := handler(&http_request)
if handler_err != nil {
log.warnf("Handler failed with error: %s", handler_err)
continue
}
marshalled_response := http.marshall_response(
&http_response,