diff --git a/lightbug_http/header.mojo b/lightbug_http/header.mojo index 3e9db7a..c223be1 100644 --- a/lightbug_http/header.mojo +++ b/lightbug_http/header.mojo @@ -11,6 +11,7 @@ struct HeaderKey: alias CONTENT_TYPE = "content-type" alias CONTENT_LENGTH = "content-length" alias CONTENT_ENCODING = "content-encoding" + alias TRANSFER_ENCODING = "transfer-encoding" alias DATE = "date" alias LOCATION = "location" alias HOST = "host" diff --git a/lightbug_http/http.mojo b/lightbug_http/http.mojo deleted file mode 100644 index 6d8804a..0000000 --- a/lightbug_http/http.mojo +++ /dev/null @@ -1,369 +0,0 @@ -from utils.string_slice import StringSlice -from utils import Span -from small_time.small_time import now -from lightbug_http.uri import URI -from lightbug_http.utils import ByteReader, ByteWriter -from lightbug_http.io.bytes import Bytes, bytes, Byte -from lightbug_http.header import Headers, HeaderKey, Header, write_header -from lightbug_http.io.sync import Duration -from lightbug_http.net import Addr, TCPAddr -from lightbug_http.strings import ( - strHttp11, - strHttp, - strSlash, - whitespace, - rChar, - nChar, - lineBreak, - to_string, -) - - -alias OK_MESSAGE = String("OK").as_bytes() -alias NOT_FOUND_MESSAGE = String("Not Found").as_bytes() -alias TEXT_PLAIN_CONTENT_TYPE = String("text/plain").as_bytes() -alias OCTET_STREAM_CONTENT_TYPE = String("application/octet-stream").as_bytes() - - -@always_inline -fn encode(owned req: HTTPRequest) -> Bytes: - return req._encoded() - - -@always_inline -fn encode(owned res: HTTPResponse) -> Bytes: - return res._encoded() - - -struct StatusCode: - alias OK = 200 - alias MOVED_PERMANENTLY = 301 - alias FOUND = 302 - alias TEMPORARY_REDIRECT = 307 - alias PERMANENT_REDIRECT = 308 - alias NOT_FOUND = 404 - - -@value -struct HTTPRequest(Formattable, Stringable): - var headers: Headers - var uri: URI - var body_raw: Bytes - - var method: String - var protocol: String - - var server_is_tls: Bool - var timeout: Duration - - @staticmethod - fn from_bytes(addr: String, max_body_size: Int, owned b: Bytes) raises -> HTTPRequest: - var reader = ByteReader(b^) - var headers = Headers() - var method: String - var protocol: String - var uri_str: String - try: - method, uri_str, protocol = headers.parse_raw(reader) - except e: - raise Error("Failed to parse request headers: " + e.__str__()) - - var uri = URI.parse_raises(addr + uri_str) - - var content_length = headers.content_length() - - if content_length > 0 and max_body_size > 0 and content_length > max_body_size: - raise Error("Request body too large") - - var request = HTTPRequest(uri, headers=headers, method=method, protocol=protocol) - - try: - request.read_body(reader, content_length, max_body_size) - except e: - raise Error("Failed to read request body: " + e.__str__()) - - return request - - fn __init__( - inout self, - uri: URI, - headers: Headers = Headers(), - method: String = "GET", - protocol: String = strHttp11, - body: Bytes = Bytes(), - server_is_tls: Bool = False, - timeout: Duration = Duration(), - ): - self.headers = headers - self.method = method - self.protocol = protocol - self.uri = uri - self.body_raw = body - self.server_is_tls = server_is_tls - self.timeout = timeout - self.set_content_length(len(body)) - if HeaderKey.CONNECTION not in self.headers: - self.set_connection_close() - if HeaderKey.HOST not in self.headers: - self.headers[HeaderKey.HOST] = uri.host - - fn set_connection_close(inout self): - self.headers[HeaderKey.CONNECTION] = "close" - - fn set_content_length(inout self, l: Int): - self.headers[HeaderKey.CONTENT_LENGTH] = str(l) - - fn connection_close(self) -> Bool: - return self.headers[HeaderKey.CONNECTION] == "close" - - @always_inline - fn read_body(inout self, inout r: ByteReader, content_length: Int, max_body_size: Int) raises -> None: - if content_length > max_body_size: - raise Error("Request body too large") - - r.consume(self.body_raw, content_length) - self.set_content_length(content_length) - - fn format_to(self, inout writer: Formatter): - writer.write(self.method, whitespace) - path = self.uri.path if len(self.uri.path) > 1 else strSlash - if len(self.uri.query_string) > 0: - path += "?" + self.uri.query_string - - writer.write(path) - - writer.write( - whitespace, - self.protocol, - lineBreak, - ) - - self.headers.format_to(writer) - writer.write(lineBreak) - writer.write(to_string(self.body_raw)) - - fn _encoded(inout self) -> Bytes: - """Encodes request as bytes. - - This method consumes the data in this request and it should - no longer be considered valid. - """ - var writer = ByteWriter() - writer.write(self.method) - writer.write(whitespace) - var path = self.uri.path if len(self.uri.path) > 1 else strSlash - if len(self.uri.query_string) > 0: - path += "?" + self.uri.query_string - writer.write(path) - writer.write(whitespace) - writer.write(self.protocol) - writer.write(lineBreak) - - self.headers.encode_to(writer) - writer.write(lineBreak) - - writer.write(self.body_raw) - - return writer.consume() - - fn __str__(self) -> String: - return to_string(self) - - -@value -struct HTTPResponse(Formattable, Stringable): - var headers: Headers - var body_raw: Bytes - - var status_code: Int - var status_text: String - var protocol: String - - @staticmethod - fn from_bytes(owned b: Bytes) raises -> HTTPResponse: - var reader = ByteReader(b^) - - var headers = Headers() - var protocol: String - var status_code: String - var status_text: String - - try: - protocol, status_code, status_text = headers.parse_raw(reader) - except e: - raise Error("Failed to parse response headers: " + e.__str__()) - - var response = HTTPResponse( - Bytes(), - headers=headers, - protocol=protocol, - status_code=int(status_code), - status_text=status_text, - ) - - try: - response.read_body(reader) - return response - except e: - raise Error("Failed to read request body: " + e.__str__()) - - fn __init__( - inout self, - body_bytes: Bytes, - headers: Headers = Headers(), - status_code: Int = 200, - status_text: String = "OK", - protocol: String = strHttp11, - ): - self.headers = headers - if HeaderKey.CONTENT_TYPE not in self.headers: - self.headers[HeaderKey.CONTENT_TYPE] = "application/octet-stream" - self.status_code = status_code - self.status_text = status_text - self.protocol = protocol - self.body_raw = body_bytes - if HeaderKey.CONNECTION not in self.headers: - self.set_connection_keep_alive() - if HeaderKey.CONTENT_LENGTH not in self.headers: - self.set_content_length(len(body_bytes)) - if HeaderKey.DATE not in self.headers: - try: - var current_time = now(utc=True).__str__() - self.headers[HeaderKey.DATE] = current_time - except: - pass - - fn get_body_bytes(self) -> Bytes: - return self.body_raw - - @always_inline - fn set_connection_close(inout self): - self.headers[HeaderKey.CONNECTION] = "close" - - @always_inline - fn set_connection_keep_alive(inout self): - self.headers[HeaderKey.CONNECTION] = "keep-alive" - - fn connection_close(self) -> Bool: - return self.headers[HeaderKey.CONNECTION] == "close" - - @always_inline - fn set_content_length(inout self, l: Int): - self.headers[HeaderKey.CONTENT_LENGTH] = str(l) - - @always_inline - fn content_length(inout self) -> Int: - try: - return int(self.headers[HeaderKey.CONTENT_LENGTH]) - except: - return 0 - - fn is_redirect(self) -> Bool: - return ( - self.status_code == StatusCode.MOVED_PERMANENTLY - or self.status_code == StatusCode.FOUND - or self.status_code == StatusCode.TEMPORARY_REDIRECT - or self.status_code == StatusCode.PERMANENT_REDIRECT - ) - - @always_inline - fn read_body(inout self, inout r: ByteReader) raises -> None: - r.consume(self.body_raw, self.content_length()) - self.set_content_length(len(self.body_raw)) - - fn format_to(self, inout writer: Formatter): - writer.write( - self.protocol, - whitespace, - self.status_code, - whitespace, - self.status_text, - lineBreak, - "server: lightbug_http", - lineBreak, - ) - - self.headers.format_to(writer) - - writer.write(lineBreak) - writer.write(to_string(self.body_raw)) - - fn _encoded(inout self) -> Bytes: - """Encodes response as bytes. - - This method consumes the data in this request and it should - no longer be considered valid. - """ - var writer = ByteWriter() - writer.write(self.protocol) - writer.write(whitespace) - writer.write(bytes(str(self.status_code))) - writer.write(whitespace) - writer.write(self.status_text) - writer.write(lineBreak) - writer.write("server: lightbug_http") - writer.write(lineBreak) - - if HeaderKey.DATE not in self.headers: - try: - var current_time = now(utc=True).__str__() - write_header(writer, HeaderKey.DATE, current_time) - except: - pass - - self.headers.encode_to(writer) - - writer.write(lineBreak) - writer.write(self.body_raw) - - return writer.consume() - - fn __str__(self) -> String: - return to_string(self) - - -fn OK(body: String) -> HTTPResponse: - return HTTPResponse( - headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), - body_bytes=bytes(body), - ) - - -fn OK(body: String, content_type: String) -> HTTPResponse: - return HTTPResponse( - headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), - body_bytes=bytes(body), - ) - - -fn OK(body: Bytes) -> HTTPResponse: - return HTTPResponse( - headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), - body_bytes=body, - ) - - -fn OK(body: Bytes, content_type: String) -> HTTPResponse: - return HTTPResponse( - headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), - body_bytes=body, - ) - - -fn OK(body: Bytes, content_type: String, content_encoding: String) -> HTTPResponse: - return HTTPResponse( - headers=Headers( - Header(HeaderKey.CONTENT_TYPE, content_type), - Header(HeaderKey.CONTENT_ENCODING, content_encoding), - ), - body_bytes=body, - ) - - -fn NotFound(path: String) -> HTTPResponse: - return HTTPResponse( - status_code=404, - status_text="Not Found", - headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), - body_bytes=bytes("path " + path + " not found"), - ) diff --git a/lightbug_http/http/__init__.mojo b/lightbug_http/http/__init__.mojo new file mode 100644 index 0000000..927b1a6 --- /dev/null +++ b/lightbug_http/http/__init__.mojo @@ -0,0 +1,13 @@ +from .common_response import * +from .response import * +from .request import * + + +@always_inline +fn encode(owned req: HTTPRequest) -> Bytes: + return req._encoded() + + +@always_inline +fn encode(owned res: HTTPResponse) -> Bytes: + return res._encoded() diff --git a/lightbug_http/http/common_response.mojo b/lightbug_http/http/common_response.mojo new file mode 100644 index 0000000..65b0988 --- /dev/null +++ b/lightbug_http/http/common_response.mojo @@ -0,0 +1,45 @@ +fn OK(body: String) -> HTTPResponse: + return HTTPResponse( + headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), + body_bytes=bytes(body), + ) + + +fn OK(body: String, content_type: String) -> HTTPResponse: + return HTTPResponse( + headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), + body_bytes=bytes(body), + ) + + +fn OK(body: Bytes) -> HTTPResponse: + return HTTPResponse( + headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), + body_bytes=body, + ) + + +fn OK(body: Bytes, content_type: String) -> HTTPResponse: + return HTTPResponse( + headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), + body_bytes=body, + ) + + +fn OK(body: Bytes, content_type: String, content_encoding: String) -> HTTPResponse: + return HTTPResponse( + headers=Headers( + Header(HeaderKey.CONTENT_TYPE, content_type), + Header(HeaderKey.CONTENT_ENCODING, content_encoding), + ), + body_bytes=body, + ) + + +fn NotFound(path: String) -> HTTPResponse: + return HTTPResponse( + status_code=404, + status_text="Not Found", + headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), + body_bytes=bytes("path " + path + " not found"), + ) diff --git a/lightbug_http/http/http_version.mojo b/lightbug_http/http/http_version.mojo new file mode 100644 index 0000000..c63809f --- /dev/null +++ b/lightbug_http/http/http_version.mojo @@ -0,0 +1,25 @@ +# TODO: Can't be used yet. +# This doesn't work right because of float point +# Shenaningans and round() doesn't give me what I want +@value +@register_passable("trivial") +struct HttpVersion(EqualityComparable, Stringable): + var _v: Float64 + + fn __init__(inout self, version: String) raises: + self._v = atof(version[version.find("/") + 1 :]) + + fn __eq__(self, other: Self) -> Bool: + return self._v == other._v + + fn __ne__(self, other: Self) -> Bool: + return self._v != other._v + + fn __eq__(self, other: Float64) -> Bool: + return self._v == other + + fn __ne__(self, other: Float64) -> Bool: + return self._v != other + + fn __str__(self) -> String: + return "HTTP/" + str(self._v) diff --git a/lightbug_http/http/request.mojo b/lightbug_http/http/request.mojo new file mode 100644 index 0000000..318dcae --- /dev/null +++ b/lightbug_http/http/request.mojo @@ -0,0 +1,147 @@ +from lightbug_http.io.bytes import Bytes, bytes, Byte +from lightbug_http.header import Headers, HeaderKey, Header, write_header +from lightbug_http.uri import URI +from lightbug_http.utils import ByteReader, ByteWriter +from lightbug_http.io.bytes import Bytes, bytes, Byte +from lightbug_http.io.sync import Duration +from lightbug_http.strings import ( + strHttp11, + strHttp, + strSlash, + whitespace, + rChar, + nChar, + lineBreak, + to_string, +) + + +@always_inline +fn encode(owned req: HTTPRequest) -> Bytes: + return req._encoded() + + +@value +struct HTTPRequest(Formattable, Stringable): + var headers: Headers + var uri: URI + var body_raw: Bytes + + var method: String + var protocol: String + + var server_is_tls: Bool + var timeout: Duration + + @staticmethod + fn from_bytes(addr: String, max_body_size: Int, owned b: Bytes) raises -> HTTPRequest: + var reader = ByteReader(b^) + var headers = Headers() + var method: String + var protocol: String + var uri_str: String + try: + method, uri_str, protocol = headers.parse_raw(reader) + except e: + raise Error("Failed to parse request headers: " + e.__str__()) + + var uri = URI.parse_raises(addr + uri_str) + + var content_length = headers.content_length() + + if content_length > 0 and max_body_size > 0 and content_length > max_body_size: + raise Error("Request body too large") + + var request = HTTPRequest(uri, headers=headers, method=method, protocol=protocol) + + try: + request.read_body(reader, content_length, max_body_size) + except e: + raise Error("Failed to read request body: " + e.__str__()) + + return request + + fn __init__( + inout self, + uri: URI, + headers: Headers = Headers(), + method: String = "GET", + protocol: String = strHttp11, + body: Bytes = Bytes(), + server_is_tls: Bool = False, + timeout: Duration = Duration(), + ): + self.headers = headers + self.method = method + self.protocol = protocol + self.uri = uri + self.body_raw = body + self.server_is_tls = server_is_tls + self.timeout = timeout + self.set_content_length(len(body)) + if HeaderKey.CONNECTION not in self.headers: + self.set_connection_close() + if HeaderKey.HOST not in self.headers: + self.headers[HeaderKey.HOST] = uri.host + + fn set_connection_close(inout self): + self.headers[HeaderKey.CONNECTION] = "close" + + fn set_content_length(inout self, l: Int): + self.headers[HeaderKey.CONTENT_LENGTH] = str(l) + + fn connection_close(self) -> Bool: + return self.headers[HeaderKey.CONNECTION] == "close" + + @always_inline + fn read_body(inout self, inout r: ByteReader, content_length: Int, max_body_size: Int) raises -> None: + if content_length > max_body_size: + raise Error("Request body too large") + + r.consume(self.body_raw, content_length) + self.set_content_length(content_length) + + fn format_to(self, inout writer: Formatter): + writer.write(self.method, whitespace) + path = self.uri.path if len(self.uri.path) > 1 else strSlash + if len(self.uri.query_string) > 0: + path += "?" + self.uri.query_string + + writer.write(path) + + writer.write( + whitespace, + self.protocol, + lineBreak, + ) + + self.headers.format_to(writer) + writer.write(lineBreak) + writer.write(to_string(self.body_raw)) + + fn _encoded(inout self) -> Bytes: + """Encodes request as bytes. + + This method consumes the data in this request and it should + no longer be considered valid. + """ + var writer = ByteWriter() + writer.write(self.method) + writer.write(whitespace) + var path = self.uri.path if len(self.uri.path) > 1 else strSlash + if len(self.uri.query_string) > 0: + path += "?" + self.uri.query_string + writer.write(path) + writer.write(whitespace) + writer.write(self.protocol) + writer.write(lineBreak) + + self.headers.encode_to(writer) + writer.write(lineBreak) + + writer.write(self.body_raw) + + return writer.consume() + + fn __str__(self) -> String: + return to_string(self) diff --git a/lightbug_http/http/response.mojo b/lightbug_http/http/response.mojo new file mode 100644 index 0000000..6634fd1 --- /dev/null +++ b/lightbug_http/http/response.mojo @@ -0,0 +1,175 @@ +from small_time.small_time import now +from lightbug_http.uri import URI +from lightbug_http.utils import ByteReader, ByteWriter +from lightbug_http.io.bytes import Bytes, bytes, Byte +from lightbug_http.strings import ( + strHttp11, + strHttp, + strSlash, + whitespace, + rChar, + nChar, + lineBreak, + to_string, +) + + +struct StatusCode: + alias OK = 200 + alias MOVED_PERMANENTLY = 301 + alias FOUND = 302 + alias TEMPORARY_REDIRECT = 307 + alias PERMANENT_REDIRECT = 308 + alias NOT_FOUND = 404 + + +@value +struct HTTPResponse(Formattable, Stringable): + var headers: Headers + var body_raw: Bytes + + var status_code: Int + var status_text: String + var protocol: String + + @staticmethod + fn from_bytes(owned b: Bytes) raises -> HTTPResponse: + var reader = ByteReader(b^) + + var headers = Headers() + var protocol: String + var status_code: String + var status_text: String + + try: + protocol, status_code, status_text = headers.parse_raw(reader) + except e: + raise Error("Failed to parse response headers: " + e.__str__()) + + var response = HTTPResponse( + Bytes(), + headers=headers, + protocol=protocol, + status_code=int(status_code), + status_text=status_text, + ) + + try: + response.read_body(reader) + return response + except e: + raise Error("Failed to read request body: " + e.__str__()) + + fn __init__( + inout self, + body_bytes: Bytes, + headers: Headers = Headers(), + status_code: Int = 200, + status_text: String = "OK", + protocol: String = strHttp11, + ): + self.headers = headers + if HeaderKey.CONTENT_TYPE not in self.headers: + self.headers[HeaderKey.CONTENT_TYPE] = "application/octet-stream" + self.status_code = status_code + self.status_text = status_text + self.protocol = protocol + self.body_raw = body_bytes + if HeaderKey.CONNECTION not in self.headers: + self.set_connection_keep_alive() + if HeaderKey.CONTENT_LENGTH not in self.headers: + self.set_content_length(len(body_bytes)) + if HeaderKey.DATE not in self.headers: + try: + var current_time = now(utc=True).__str__() + self.headers[HeaderKey.DATE] = current_time + except: + pass + + fn get_body_bytes(self) -> Bytes: + return self.body_raw + + @always_inline + fn set_connection_close(inout self): + self.headers[HeaderKey.CONNECTION] = "close" + + @always_inline + fn set_connection_keep_alive(inout self): + self.headers[HeaderKey.CONNECTION] = "keep-alive" + + fn connection_close(self) -> Bool: + return self.headers[HeaderKey.CONNECTION] == "close" + + @always_inline + fn set_content_length(inout self, l: Int): + self.headers[HeaderKey.CONTENT_LENGTH] = str(l) + + @always_inline + fn content_length(inout self) -> Int: + try: + return int(self.headers[HeaderKey.CONTENT_LENGTH]) + except: + return 0 + + fn is_redirect(self) -> Bool: + return ( + self.status_code == StatusCode.MOVED_PERMANENTLY + or self.status_code == StatusCode.FOUND + or self.status_code == StatusCode.TEMPORARY_REDIRECT + or self.status_code == StatusCode.PERMANENT_REDIRECT + ) + + @always_inline + fn read_body(inout self, inout r: ByteReader) raises -> None: + r.consume(self.body_raw, self.content_length()) + self.set_content_length(len(self.body_raw)) + + fn format_to(self, inout writer: Formatter): + writer.write( + self.protocol, + whitespace, + self.status_code, + whitespace, + self.status_text, + lineBreak, + "server: lightbug_http", + lineBreak, + ) + + self.headers.format_to(writer) + + writer.write(lineBreak) + writer.write(to_string(self.body_raw)) + + fn _encoded(inout self) -> Bytes: + """Encodes response as bytes. + + This method consumes the data in this request and it should + no longer be considered valid. + """ + var writer = ByteWriter() + writer.write(self.protocol) + writer.write(whitespace) + writer.write(bytes(str(self.status_code))) + writer.write(whitespace) + writer.write(self.status_text) + writer.write(lineBreak) + writer.write("server: lightbug_http") + writer.write(lineBreak) + + if HeaderKey.DATE not in self.headers: + try: + var current_time = now(utc=True).__str__() + write_header(writer, HeaderKey.DATE, current_time) + except: + pass + + self.headers.encode_to(writer) + + writer.write(lineBreak) + writer.write(self.body_raw) + + return writer.consume() + + fn __str__(self) -> String: + return to_string(self) diff --git a/tests/lightbug_http/test_http.mojo b/tests/lightbug_http/test_http.mojo index 2a9a1cb..a8acc6a 100644 --- a/tests/lightbug_http/test_http.mojo +++ b/tests/lightbug_http/test_http.mojo @@ -9,7 +9,6 @@ from lightbug_http.strings import to_string alias default_server_conn_string = "http://localhost:8080" - def test_encode_http_request(): var uri = URI.parse_raises(default_server_conn_string + "/foobar?baz") var req = HTTPRequest( @@ -43,3 +42,8 @@ def test_encode_http_response(): testing.assert_equal(res_encoded, expected_full) testing.assert_equal(res_encoded, as_str) + + +# def test_http_version_parse(): +# var v1 = HttpVersion("HTTP/1.1") +# testing.assert_equal(v1, 1.1) \ No newline at end of file