diff --git a/test/autobahn.gleam b/dev/autobahn.gleam similarity index 97% rename from test/autobahn.gleam rename to dev/autobahn.gleam index 20a4ea4..e067ca6 100644 --- a/test/autobahn.gleam +++ b/dev/autobahn.gleam @@ -28,7 +28,6 @@ pub fn main() -> Nil { on_close: fn(_conn, _state) { Nil }, ) }) - |> ewe.enable_ipv6() |> ewe.bind("0.0.0.0") |> ewe.listening(port: 8080) |> ewe.supervised() diff --git a/dev/benchmark.gleam b/dev/benchmark.gleam new file mode 100644 index 0000000..31ecddc --- /dev/null +++ b/dev/benchmark.gleam @@ -0,0 +1,45 @@ +import ewe.{type Request, type Response} +import gleam/erlang/process +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/result + +pub fn main() { + let assert Ok(_) = + ewe.new(handle_request) + |> ewe.listening(port: 8080) + |> ewe.start + + process.sleep_forever() +} + +fn handle_request(req: Request) -> Response { + case req.method, request.path_segments(req) { + http.Get, [] -> + response.new(200) + |> response.set_body(ewe.Empty) + http.Get, ["user", id] -> + response.new(200) |> response.set_body(ewe.TextData(id)) + http.Post, ["user"] -> { + case ewe.read_body(req, 40_000_000) { + Ok(req) -> { + let content_type = + req + |> request.get_header("content-type") + |> result.unwrap("application/octet-stream") + + response.new(200) + |> response.set_body(ewe.BitsData(req.body)) + |> response.prepend_header("content-type", content_type) + } + Error(_) -> + response.new(413) + |> response.set_body(ewe.Empty) + } + } + _, _ -> + response.new(404) + |> response.set_body(ewe.Empty) + } +} diff --git a/test/https.gleam b/dev/https.gleam similarity index 100% rename from test/https.gleam rename to dev/https.gleam diff --git a/test/preview.gleam b/dev/preview.gleam similarity index 100% rename from test/preview.gleam rename to dev/preview.gleam diff --git a/test/bench.gleam b/test/bench.gleam deleted file mode 100644 index ab0afd0..0000000 --- a/test/bench.gleam +++ /dev/null @@ -1,50 +0,0 @@ -import ewe -import gleam/erlang/process -import gleam/http -import gleam/http/request.{type Request} -import gleam/http/response -import gleam/result - -pub fn main() { - let empty_response = - response.new(200) - |> response.set_body(ewe.Empty) - - let not_found = - response.new(404) - |> response.set_body(ewe.Empty) - - let too_large = - response.new(413) - |> response.set_body(ewe.Empty) - - let assert Ok(_) = - fn(req: Request(ewe.Connection)) { - case req.method, request.path_segments(req) { - http.Get, [] -> empty_response - http.Get, ["user", id] -> - response.new(200) |> response.set_body(ewe.TextData(id)) - http.Post, ["user"] -> { - case ewe.read_body(req, 40_000_000) { - Ok(req) -> { - let content_type = - req - |> request.get_header("content-type") - |> result.unwrap("application/octet-stream") - - response.new(200) - |> response.set_body(ewe.BitsData(req.body)) - |> response.prepend_header("content-type", content_type) - } - Error(_) -> too_large - } - } - _, _ -> not_found - } - } - |> ewe.new - |> ewe.listening(port: 8080) - |> ewe.start() - - process.sleep_forever() -} diff --git a/test/client/http.gleam b/test/client/http.gleam new file mode 100644 index 0000000..0c3d3c0 --- /dev/null +++ b/test/client/http.gleam @@ -0,0 +1,64 @@ +import gleam/bit_array +import gleam/http/response.{type Response, Response} +import gleam/int +import gleam/list +import gleam/result +import gleam/string + +pub type ParseError { + InvalidStatus + InvalidHeaders + MalformedResponse +} + +pub fn parse(data: BitArray) -> Result(Response(String), ParseError) { + case bit_array.to_string(data) { + Ok(data) -> + case string.split_once(data, "\r\n\r\n") { + Ok(#(lines, body)) -> do_parse(lines, body) + Error(Nil) -> do_parse(data, "") + } + + Error(Nil) -> Error(MalformedResponse) + } +} + +fn do_parse(lines: String, body: String) -> Result(Response(String), ParseError) { + case string.split(lines, "\r\n") { + [status, ..headers] -> { + use status <- result.try(parse_status(status)) + use headers <- result.try(parse_headers(headers, [])) + + Ok(Response(status:, headers:, body:)) + } + _ -> Error(MalformedResponse) + } +} + +fn parse_status(status: String) -> Result(Int, ParseError) { + case string.split(status, " ") { + [_version, status, ..] -> + int.parse(status) + |> result.replace_error(InvalidStatus) + _ -> Error(InvalidStatus) + } +} + +fn parse_headers( + headers: List(String), + acc: List(#(String, String)), +) -> Result(List(#(String, String)), ParseError) { + case headers { + [] -> Ok(list.reverse(acc)) + [header, ..remaining] -> { + case string.split_once(header, ": "), string.split_once(header, ":") { + Ok(#(name, value)), _ -> + parse_headers(remaining, [#(string.lowercase(name), value), ..acc]) + Error(Nil), Ok(#(name, value)) -> + [#(string.lowercase(name), string.trim(value)), ..acc] + |> parse_headers(remaining, _) + Error(Nil), Error(Nil) -> Error(InvalidHeaders) + } + } + } +} diff --git a/test/client/tcp.gleam b/test/client/tcp.gleam index 6b538d7..45fb1f3 100644 --- a/test/client/tcp.gleam +++ b/test/client/tcp.gleam @@ -7,19 +7,19 @@ import glisten/tcp pub fn with_socket( port port: Int, active active: Bool, - callback callback: fn(socket.Socket) -> Nil, -) { + callback callback: fn(socket.Socket) -> a, +) -> a { let assert Ok(socket) = tcp_connect(charlist.from_string("localhost"), port, [ from(atom.create("binary")), from(#(atom.create("active"), active)), ]) - callback(socket) + let result = callback(socket) let assert Ok(Nil) = tcp.close(socket) - Nil + result } @external(erlang, "gen_tcp", "connect") diff --git a/test/clock.gleam b/test/clock.gleam deleted file mode 100644 index ab970ba..0000000 --- a/test/clock.gleam +++ /dev/null @@ -1,38 +0,0 @@ -import ewe/internal/clock -import gleam/float -import gleam/int -import gleam/io - -pub fn main() { - let iterations = 10_000_000 - - clock.get_http_date() - - io.println("Getting HTTP date speed test") - - let begin = now_microseconds() - int.range(0, iterations, "", fn(_, _) { clock.get_http_date() }) - let end = now_microseconds() - - let total_duration = end - begin - let average_nanoseconds = - { int.to_float(total_duration) *. 1000.0 } /. int.to_float(iterations) - - io.println({ - "Total time for " - <> int.to_string(iterations) - <> " iterations: " - <> int.to_string(total_duration) - <> " µs (microsecond)" - }) - - { - "Average time per call: " - <> float.to_string(average_nanoseconds) - <> " ns (nanosecond)" - } - |> io.println() -} - -@external(erlang, "ewe_ffi", "now_microseconds") -fn now_microseconds() -> Int diff --git a/test/h1spec_test.gleam b/test/h1spec_test.gleam index 792c8e1..6189dda 100644 --- a/test/h1spec_test.gleam +++ b/test/h1spec_test.gleam @@ -1,7 +1,9 @@ +import client/http import client/tcp as client import gleam/bytes_tree +import gleam/http/response.{type Response} import gleam/list -import glisten/socket +import glisten/socket.{type Socket} import glisten/tcp import server @@ -14,52 +16,32 @@ fn expect_timeout(req: String) -> Nil { assert tcp.receive_timeout(socket, 0, 500) == Error(socket.Timeout) } -fn run_request(socket: socket.Socket, req: String) -> server.HttpResponse { +fn run_request(socket: Socket, req: String) -> Response(String) { let assert Ok(Nil) = tcp.send(socket, bytes_tree.from_string(req)) let assert Ok(resp) = tcp.receive_timeout(socket, 0, 1000) - let assert Ok(resp) = server.parse_http_response(resp) + let assert Ok(resp) = http.parse(resp) resp } -fn with_expected_status( - resp: server.HttpResponse, - status: List(#(Int, Int)), -) -> Nil { +pub fn expect_status(req: String, status: List(#(Int, Int))) -> Response(String) { + let socket_address = server.start(server.echoer()) + use socket <- client.with_socket(socket_address.port, active: False) + + let resp = run_request(socket, req) + assert list.fold_until(over: status, from: False, with: fn(_, status) { let #(start, end) = status - case resp.status_code { + case resp.status { status if status >= start && status <= end -> list.Stop(True) _ -> list.Continue(False) } }) == True -} - -pub fn expect_status(req: String, status: List(#(Int, Int))) { - let socket_address = server.start(server.echoer()) - use socket <- client.with_socket(socket_address.port, active: False) - - let resp = run_request(socket, req) - with_expected_status(resp, status) - Nil -} - -pub fn expect_status_with_body( - req: String, - status: List(#(Int, Int)), - expected_body: String, -) { - let socket_address = server.start(server.echoer()) - use socket <- client.with_socket(socket_address.port, active: False) - - let resp = run_request(socket, req) - with_expected_status(resp, status) - - assert resp.body == expected_body + resp } pub fn fragmented_method_test() { @@ -218,25 +200,31 @@ pub fn invalid_line_ending_test() { } pub fn valid_post_request_with_body_test() { - expect_status_with_body( - "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello", - [#(200, 299)], - "hello", - ) + let resp = + expect_status( + "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello", + [#(200, 299)], + ) + + assert resp.body == "hello" } pub fn chunked_transfer_encoding_test() { - expect_status_with_body( - "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\nc\r\nHellO world1\r\n0\r\n\r\n", - [#(200, 299)], - "HellO world1", - ) + let resp = + expect_status( + "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\nc\r\nHellO world1\r\n0\r\n\r\n", + [#(200, 299)], + ) + + assert resp.body == "HellO world1" } pub fn conflicting_transfer_encoding_and_content_length_test() { - expect_status_with_body( - "POST / HTTP/1.1\r\nHost: example.com\r\ncontent-LengtH: 5\r\nTransFer-Encoding: chunked\r\n\r\nc\r\nHellO world1\r\n0\r\n\r\n", - [#(400, 499), #(200, 299)], - "HellO world1", - ) + let resp = + expect_status( + "POST / HTTP/1.1\r\nHost: example.com\r\ncontent-LengtH: 5\r\nTransFer-Encoding: chunked\r\n\r\nc\r\nHellO world1\r\n0\r\n\r\n", + [#(400, 499), #(200, 299)], + ) + + assert resp.body == "HellO world1" } diff --git a/test/server.gleam b/test/server.gleam index ffc51ec..180463d 100644 --- a/test/server.gleam +++ b/test/server.gleam @@ -1,24 +1,31 @@ -import gleam/bit_array -import gleam/dict import gleam/erlang/process import gleam/http/request import gleam/http/response -import gleam/int import gleam/result -import gleam/string import ewe -pub fn hi() { +pub fn start(builder: ewe.Builder) -> ewe.SocketAddress { + let name = process.new_name("ewe_test_server") + + let _ = + ewe.with_name(builder, name) + |> ewe.listening_random() + |> ewe.quiet() + |> ewe.start() + + ewe.get_server_info(name) +} + +pub fn hi() -> ewe.Builder { ewe.new(fn(_req) { - "hi" - |> ewe.TextData - |> response.set_body(response.new(200), _) + response.new(200) + |> response.set_body(ewe.TextData("hi")) |> response.set_header("content-type", "text/plain; charset=utf-8") }) } -pub fn echoer() { +pub fn echoer() -> ewe.Builder { ewe.new(fn(req) { let content_type = request.get_header(req, "content-type") @@ -26,141 +33,11 @@ pub fn echoer() { case ewe.read_body(req, 10_240) { Ok(req) -> { - ewe.BitsData(req.body) - |> response.set_body(response.new(200), _) + response.new(200) + |> response.set_body(ewe.BitsData(req.body)) |> response.set_header("content-type", content_type) } Error(_) -> response.new(400) |> response.set_body(ewe.Empty) } }) } - -pub fn start(builder: ewe.Builder) { - let name = process.new_name("ewe_test_server") - - let _ = - ewe.with_name(builder, name) - |> ewe.listening_random() - |> ewe.quiet() - |> ewe.start() - - ewe.get_server_info(name) -} - -pub type HttpResponse { - HttpResponse( - version: String, - status_code: Int, - reason_phrase: String, - headers: dict.Dict(String, String), - body: String, - ) -} - -pub type ParseError { - InvalidStatusLine - InvalidHeaders - MalformedResponse -} - -pub fn parse_http_response( - raw_response: BitArray, -) -> Result(HttpResponse, ParseError) { - case bit_array.to_string(raw_response) { - Ok(response_str) -> parse_response_string(response_str) - Error(_) -> Error(MalformedResponse) - } -} - -fn parse_response_string(response: String) -> Result(HttpResponse, ParseError) { - case string.split_once(response, "\r\n\r\n") { - Ok(#(headers_part, body)) -> { - let lines = string.split(headers_part, "\r\n") - - case lines { - [status_line, ..header_lines] -> { - use #(version, status_code, reason_phrase) <- result.try( - parse_status_line(status_line), - ) - - use headers <- result.try(parse_headers(header_lines, dict.new())) - - Ok(HttpResponse( - version: version, - status_code: status_code, - reason_phrase: reason_phrase, - headers: headers, - body: body, - )) - } - _ -> Error(MalformedResponse) - } - } - Error(_) -> { - let lines = string.split(response, "\r\n") - - case lines { - [status_line, ..header_lines] -> { - use #(version, status_code, reason_phrase) <- result.try( - parse_status_line(status_line), - ) - - use headers <- result.try(parse_headers(header_lines, dict.new())) - - Ok(HttpResponse( - version: version, - status_code: status_code, - reason_phrase: reason_phrase, - headers: headers, - body: "", - )) - } - _ -> Error(MalformedResponse) - } - } - } -} - -fn parse_status_line( - status_line: String, -) -> Result(#(String, Int, String), ParseError) { - case string.split(status_line, " ") { - [version, status_code_str, ..reason_parts] -> { - case int.parse(status_code_str) { - Ok(status_code) -> { - let reason_phrase = string.join(reason_parts, " ") - Ok(#(version, status_code, reason_phrase)) - } - Error(_) -> Error(InvalidStatusLine) - } - } - _ -> Error(InvalidStatusLine) - } -} - -fn parse_headers( - lines: List(String), - headers: dict.Dict(String, String), -) -> Result(dict.Dict(String, String), ParseError) { - case lines { - [] -> Ok(headers) - [header_line, ..rest] -> { - case string.split_once(header_line, ": ") { - Ok(#(name, value)) -> { - let headers = dict.insert(headers, string.lowercase(name), value) - parse_headers(rest, headers) - } - Error(_) -> { - case string.split_once(header_line, ":") { - Ok(#(name, value)) -> { - let headers = - dict.insert(headers, string.lowercase(name), string.trim(value)) - parse_headers(rest, headers) - } - Error(_) -> Error(InvalidHeaders) - } - } - } - } - } -}