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,