From 1368b7b69bca92296cad745ec9b076775c03d291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20M=C3=A5rdbrink?= Date: Mon, 3 Nov 2025 13:50:38 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + .gitmodules | 4 + README.md | 1 + fjord_test.odin | 43 +++++++++ http/http.odin | 79 ++++++++++++++++ http/marshaller.odin | 188 ++++++++++++++++++++++++++++++++++++ odinfmt.json | 7 ++ radix_tree | 1 + router.odin | 42 +++++++++ server.odin | 220 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 588 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 fjord_test.odin create mode 100644 http/http.odin create mode 100644 http/marshaller.odin create mode 100644 odinfmt.json create mode 160000 radix_tree create mode 100644 router.odin create mode 100644 server.odin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6683d93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.dSYM +fjord +*.bin diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c06c259 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "radix_tree"] + path = radix_tree + url = https://codeberg.org/hugomardbrink/odin-radixtree + update = none diff --git a/README.md b/README.md new file mode 100644 index 0000000..83d70f0 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +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. diff --git a/fjord_test.odin b/fjord_test.odin new file mode 100644 index 0000000..7314be2 --- /dev/null +++ b/fjord_test.odin @@ -0,0 +1,43 @@ +package fjord + +import "core:log" +import "core:net" + +import "core:testing" + +import http "http" + +Error :: enum {} + +handler :: proc(request: ^http.Request) -> (http.Response, Error) { + body := "
Hello
" + response := http.make_response(.Ok, transmute([]byte)body, .Html) + + 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) + + return response, nil +} + +@(test) +test_basic_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_handler(&server, .GET, {}, handler) + server_add_handler(&server, .GET, {"hello", "{name-thing}"}, handler) + listen_and_serve(&server) +} diff --git a/http/http.odin b/http/http.odin new file mode 100644 index 0000000..7718931 --- /dev/null +++ b/http/http.odin @@ -0,0 +1,79 @@ +package http + +import "core:strconv" + +RequestError :: enum { + InvalidRequestLine, + InvalidMethod, + InvalidHeaderFormat, + IncompleteRequest, + ContentLengthMismatch, + FailedParsing, + NetworkError, +} + +ResponseError :: enum { + InvalidContentType, + InvalidStatus, +} + +Header :: map[string]string + +Status :: enum u32 { + Ok = 200, + BadRequest = 400, + NotFound = 404, + InternalServerError = 500, +} + +ContentType :: enum { + Html, + Image, +} + +Response :: struct { + header: Header, + proto_version: string, + status: Status, + body: []byte, +} + +Method :: enum { + GET, + POST, + PUT, + DELETE, +} + +Request :: struct { + header: Header, + proto_version: string, + method: Method, + path: string, + body: []byte, + path_variables: map[string]string, +} + +make_response :: proc( + status: Status, + body: []byte, + content_type: ContentType, + allocator := context.temp_allocator, +) -> Response { + response := Response { + status = status, + proto_version = "HTTP/1.1", + header = make(Header, allocator), + body = body, + } + + buf := make([]byte, 32, allocator) + response.header["Content-Length"] = strconv.write_uint( + buf[:], + u64(len(body)), + 10, + ) + response.header["Content-Type"] = str_from_content_type(content_type) + + return response +} diff --git a/http/marshaller.odin b/http/marshaller.odin new file mode 100644 index 0000000..8f9fe56 --- /dev/null +++ b/http/marshaller.odin @@ -0,0 +1,188 @@ +package http + +import "core:bufio" +import "core:bytes" +import "core:io" +import "core:log" +import "core:net" +import "core:strconv" +import "core:strings" +import "core:unicode" + +@(private) +method_from_str :: proc(method_str: string) -> Method { + switch method_str { + case "GET": + return .GET + case "DELETE": + return .DELETE + case "PUT": + return .PUT + case "POST": + return .POST + } + unreachable() +} + +@(private) +str_from_content_type :: proc(content_type: ContentType) -> string { + switch content_type { + case .Html: + return "text/html" + case .Image: + return "image/jpeg" + } + unreachable() +} + +@(private) +status_text :: proc(status: Status) -> string { + switch status { + case .Ok: + return "OK" + case .NotFound: + return "Not found" + case .BadRequest: + return "Bad request" + case .InternalServerError: + return "Internal server error" + } + + unreachable() +} + +@(private) +unmarshall_request_line :: proc( + request: ^Request, + reader: ^bufio.Reader, +) -> 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, {' '}) + if len(parts) != 3 do return .InvalidRequestLine + + request.method = method_from_str(string(parts[0])) + request.path = string(parts[1]) + request.proto_version = string(parts[2]) + + return nil +} + +@(private) +unmarshall_request_headers :: proc( + request: ^Request, + reader: ^bufio.Reader, + allocator := context.temp_allocator, +) -> RequestError { + request.header = make(Header, allocator) + + for { + header_line, io_err := bufio.reader_read_slice(reader, '\n') + if io_err != nil do return .InvalidHeaderFormat + + header_line = sanitize_byte_slice(header_line) + if len(header_line) == 0 do break + + parts := bytes.split_n(header_line, {':'}, 2) + if len(parts) != 2 do return .InvalidHeaderFormat + + key := string(bytes.trim_space(parts[0])) + value := string(bytes.trim_space(parts[1])) + + request.header[key] = value + } + + return nil +} + +@(private) +unmarshall_request_body :: proc( + request: ^Request, + reader: ^bufio.Reader, + allocator := context.temp_allocator, +) -> RequestError { + content_length_str, ok := request.header["Content-Length"] + + if ok { + content_length, ok := strconv.parse_uint(content_length_str) + if !ok do return .InvalidHeaderFormat + request.body = make([]byte, content_length, allocator) + + _, err := bufio.reader_read(reader, request.body) + if err != nil do return .FailedParsing + + } else { + request.body = {} + } + + return nil +} + +@(private) +sanitize_byte_slice :: proc(slice: []byte) -> []byte { + return bytes.trim(slice, {'\n', '\r'}) +} + +unmarshall_request :: proc( + data: []byte, + allocator := context.temp_allocator, +) -> ( + request: Request, + request_err: RequestError, +) { + if len(data) == 0 do return request, .IncompleteRequest + + byte_reader: bytes.Reader + stream := bytes.reader_init(&byte_reader, data) + + reader: bufio.Reader + bufio.reader_init(&reader, io.to_reader(stream), allocator = allocator) + defer bufio.reader_destroy(&reader) + + unmarshall_request_line(&request, &reader) or_return + unmarshall_request_headers(&request, &reader, allocator) or_return + unmarshall_request_body(&request, &reader, allocator) or_return + + return request, nil +} + +marshall_response :: proc( + response: ^Response, + allocator := context.temp_allocator, +) -> []byte { + buffer: bytes.Buffer + bytes.buffer_init_allocator(&buffer, 0, 0, allocator) + + bytes.buffer_write_string(&buffer, response.proto_version) + bytes.buffer_write_string(&buffer, " ") + + status_code_buf := make([]byte, 4, allocator) + status_code_str := strconv.write_uint( + status_code_buf[:], + u64(response.status), + 10, + ) + + bytes.buffer_write_string(&buffer, status_code_str) + bytes.buffer_write_string(&buffer, " ") + + status_msg := status_text(response.status) + bytes.buffer_write_string(&buffer, status_msg) + bytes.buffer_write_string(&buffer, "\r\n") + + for key, value in response.header { + bytes.buffer_write_string(&buffer, key) + bytes.buffer_write_string(&buffer, ": ") + bytes.buffer_write_string(&buffer, value) + bytes.buffer_write_string(&buffer, "\r\n") + } + + bytes.buffer_write_string(&buffer, "\r\n") + + bytes.buffer_write(&buffer, response.body) + + return bytes.buffer_to_bytes(&buffer) +} diff --git a/odinfmt.json b/odinfmt.json new file mode 100644 index 0000000..05c5c93 --- /dev/null +++ b/odinfmt.json @@ -0,0 +1,7 @@ + { + "character_width": 80, + "tabs": false, + "tabs_width": 4, + "newline_style": "lf", + "brace_style": "allman" + } diff --git a/radix_tree b/radix_tree new file mode 160000 index 0000000..6fe8dc7 --- /dev/null +++ b/radix_tree @@ -0,0 +1 @@ +Subproject commit 6fe8dc79ee3aa57f3a8b9a427bedf7bb24cfd809 diff --git a/router.odin b/router.odin new file mode 100644 index 0000000..4267f78 --- /dev/null +++ b/router.odin @@ -0,0 +1,42 @@ +package fjord + +import "core:path/slashpath" +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, +} + +Router :: struct { + radix_tree: rxt.RadixTree(EndpointHandler), +} + +router_init :: proc(router: ^Router, allocator := context.allocator) { + rxt.init(&router.radix_tree, allocator) +} + +router_destroy :: proc(router : ^Router) { + rxt.destroy(&router.radix_tree) +} + +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, key: []string, value: EndpointHandler) { + joined_path := slashpath.join(key, context.allocator) + defer delete(joined_path) + + rxt.insert(&router.radix_tree, joined_path[:], value) +} + +router_remove_route :: proc(router: ^Router, key: string) -> (ok: bool){ + return rxt.remove(&router.radix_tree, key) +} diff --git a/server.odin b/server.odin new file mode 100644 index 0000000..348c6ef --- /dev/null +++ b/server.odin @@ -0,0 +1,220 @@ +package fjord + +import "core:bufio" +import "core:bytes" +import "core:io" +import "core:log" +import "core:net" +import "core:path/slashpath" +import "core:strconv" +import "core:strings" +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, + ), +} + +@(private) +request_ended :: proc(buf: []byte) -> bool { + HTTP_END_SEQUENCE :: []byte{'\r', '\n', '\r', '\n'} + return bytes.index(buf, HTTP_END_SEQUENCE) != -1 +} + +read_connection :: proc( + client_socket: net.TCP_Socket, + allocator := context.temp_allocator, +) -> ( + data: []byte, + err: http.RequestError, +) { + TCP_CHUNK_SIZE :: 1024 + buffer: bytes.Buffer + bytes.buffer_init_allocator(&buffer, 0, 0, allocator) + + chunk: [TCP_CHUNK_SIZE]byte + for { + bytes_read, net_err := net.recv_tcp(client_socket, chunk[:]) + if net_err != nil do return data, .NetworkError + + if bytes_read == 0 do break + bytes.buffer_write(&buffer, chunk[:bytes_read]) + + if request_ended(buffer.buf[:]) do break + } + + return buffer.buf[:], nil +} + +server_init :: proc( + server: ^Server($Error_Type), + endpoint: net.Endpoint, + allocator := context.allocator, +) { + server^ = Server(Error_Type) { + endpoint = endpoint, + not_found_handler = proc( + request: ^http.Request, + ) -> ( + http.Response, + Error_Type, + ) { + body := "
Not found
" + http_response := http.make_response( + .NotFound, + transmute([]byte)body, + .Html, + ) + return http_response, nil + }, + } + + router_init(&server.router) +} + +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 +server_set_not_found_handler :: proc( + server: ^Server($Error_Type), + not_found_handler: #type proc( + request: ^http.Request, + ) -> ( + http.Response, + Error_Type, + ), +) { + server.not_found_handler = not_found_handler +} + +match_handler_pattern :: proc( + server: ^Server($Error_Type), + method: http.Method, + path: string, + allocator := context.allocator, +) -> ( + 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) + + handler = server.handlers[identifier].procedure + + return handler, path_variables +} + +listen_and_serve :: proc(server: ^Server($Error_Type)) { + server_socket, net_err := net.listen_tcp(server.endpoint) + log.assert(net_err == nil, "Couldn't create TCP socket") + defer net.close(server_socket) + + for { + 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) + continue + } + defer net.close(client_socket) + + data, read_err := read_connection( + client_socket, + context.temp_allocator, + ) + if read_err != nil { + log.warnf("Failed to read request, reason: %s", read_err) + continue + } + + http_request, unmarshall_err := http.unmarshall_request( + data, + context.temp_allocator, + ) + if unmarshall_err != nil { + log.warnf( + "Failed to unmarshall request, reason: %s", + unmarshall_err, + ) + continue + } + + handler: #type proc( + request: ^http.Request, + ) -> ( + http.Response, + Error_Type, + ) + handler, http_request.path_variables = match_handler_pattern( + server, + http_request.method, + http_request.path, + ) + + http_response, err := handler(&http_request) + + marshalled_response := http.marshall_response( + &http_response, + context.temp_allocator, + ) + net.send_tcp(client_socket, marshalled_response) + + free_all(context.temp_allocator) + } +}