From 4316fb2a7347a85dde1e56dd36abc5607396573c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20M=C3=A5rdbrink?= Date: Sun, 9 Nov 2025 11:11:15 +0100 Subject: [PATCH] Add routing --- .gitmodules | 4 - README.md | 100 ++++++++++++++++++- fjord_test.odin | 230 ++++++++++++++++++++++++++++++++++++++++--- http/marshaller.odin | 5 +- radix_tree | 1 - router.odin | 192 +++++++++++++++++++++++++++++++----- server.odin | 111 +++++++++------------ 7 files changed, 534 insertions(+), 109 deletions(-) delete mode 160000 radix_tree diff --git a/.gitmodules b/.gitmodules index c06c259..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +0,0 @@ -[submodule "radix_tree"] - path = radix_tree - url = https://codeberg.org/hugomardbrink/odin-radixtree - update = none diff --git a/README.md b/README.md index 83d70f0..93ab2ac 100644 --- a/README.md +++ b/README.md @@ -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( + {"

Hello, ", name, "!

"}, + 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( + {"

User ", user_id, " - Post ", post_id, "

"}, + 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 . +``` diff --git a/fjord_test.odin b/fjord_test.odin index 7314be2..bc89462 100644 --- a/fjord_test.odin +++ b/fjord_test.odin @@ -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 := "
Hello
" - 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( + {"
Hello first", entity, "
"}, + 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 := "
Hello
" - 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( + {"
Hello second", entity, "
"}, + 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( + {"
Hello third", entity, "and", other_entity, "
"}, + 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", + "
Hello firstworld
", + ) + assert_endpoint( + "http://127.0.0.1:8080/hello/lonely%20world/only", + "
Hello secondlonely%20world
", + ) + assert_endpoint( + "http://127.0.0.1:8080/hello/world/and/worlds%20friend", + "
Hello thirdworldandworlds%20friend
", + ) + + shutdown_concurrent_server(&server, t) + + free_all(context.temp_allocator) } diff --git a/http/marshaller.odin b/http/marshaller.odin index 8f9fe56..973dfed 100644 --- a/http/marshaller.odin +++ b/http/marshaller.odin @@ -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])) diff --git a/radix_tree b/radix_tree deleted file mode 160000 index 6fe8dc7..0000000 --- a/radix_tree +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6fe8dc79ee3aa57f3a8b9a427bedf7bb24cfd809 diff --git a/router.odin b/router.odin index 4267f78..045b2c5 100644 --- a/router.odin +++ b/router.odin @@ -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( - request: ^http.Request, - ) -> ( - http.Response, - Error_Type, - ), - path_variables: []string, +PathVariable :: struct { + name: string, } -Router :: struct { - radix_tree: rxt.RadixTree(EndpointHandler), +Static :: struct {} + +RouterNodeType :: union { + Static, + PathVariable, } -router_init :: proc(router: ^Router, allocator := context.allocator) { - rxt.init(&router.radix_tree, allocator) +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_destroy :: proc(router : ^Router) { - rxt.destroy(&router.radix_tree) +Router :: struct($Error_Type: typeid) { + root_node: ^RouterNode(Error_Type), + allocator: runtime.Allocator, } -router_lookup :: proc(router: ^Router, key: string) -> (endpoint_handler: EndpointHandler, ok: bool) { - return rxt.lookup(&router.radix_tree, key) +@(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, + ), + 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_add_route :: proc(router: ^Router, key: []string, value: EndpointHandler) { - joined_path := slashpath.join(key, context.allocator) - defer delete(joined_path) +router_init :: proc( + router: ^Router($Error_Type), + allocator: runtime.Allocator, +) { + router^ = Router(Error_Type) { + root_node = new(RouterNode(Error_Type), allocator), + allocator = allocator, + } - rxt.insert(&router.radix_tree, joined_path[:], value) + router_node_init(router, router.root_node, nil) } -router_remove_route :: proc(router: ^Router, key: string) -> (ok: bool){ - return rxt.remove(&router.radix_tree, key) +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_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_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_remove_route :: proc( + router: ^Router($Error_Type), + method: http.Method, + path: []string, +) -> ( + ok: bool, +) { + current_node := router.root_node + + for segment_name in path { + node, ok := current_node.children[segment_name] + if !ok do return false + current_node = node + } + + if method not_in current_node.handlers { + return false + } + + delete_key(¤t_node.handlers, method) + return true } diff --git a/server.odin b/server.odin index 348c6ef..857f02a 100644 --- a/server.odin +++ b/server.odin @@ -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,